bos/breadhelp/src/ui/home.rs
Breadway b2f0b2a500 breadhelp 0.2.0: live guided tour overlay replacing static onboarding
Replaces the old in-window onboarding wizard with a real screen-wide
tour: dim + spotlight cutout around the actual on-screen component
(breadbar, breadbox), floating callout teaching the shortcut, and
event-driven confirmation via real Hyprland/breadd signals instead of
click-through fakery.
2026-07-15 18:48:10 +08:00

180 lines
6.2 KiB
Rust

use gtk4::prelude::*;
use gtk4::{
Align, ApplicationWindow, Box as GBox, Button, DropDown, FlowBox, Label, Orientation, Revealer,
RevealerTransitionType, ScrolledWindow, StringList, Widget,
};
use crate::config::Mode;
use crate::content::keybinds::Keybind;
use super::{keybind_viewer, troubleshoot_wizard};
const FIX_SOUND: &str = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket";
const FIX_WIFI: &str = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on";
const FIX_UPDATE: &str = "bos-update";
const FIX_BACKUP: &str = "snapper -c root create --description 'breadhelp manual backup'";
/// Handle back to the parts of Home that get updated after construction —
/// the suggestion banner (from breadd, see `services::breadd`) and the
/// one-click-fix toast, neither of which exist until the widget tree is
/// built once.
pub struct Home {
pub root: Widget,
banner_revealer: Revealer,
banner_label: Label,
}
impl Home {
pub fn set_suggestion(&self, text: Option<&str>) {
match text {
Some(t) => {
self.banner_label.set_label(t);
self.banner_revealer.set_reveal_child(true);
}
None => self.banner_revealer.set_reveal_child(false),
}
}
}
pub fn build(
binds: &[Keybind],
parent_window: &ApplicationWindow,
initial_mode: Mode,
on_mode_change: impl Fn(Mode) + 'static,
) -> Home {
let vbox = GBox::new(Orientation::Vertical, 16);
vbox.add_css_class("view-content");
let header = GBox::new(Orientation::Horizontal, 12);
let title = Label::new(Some("Home"));
title.add_css_class("title");
title.set_xalign(0.0);
title.set_hexpand(true);
header.append(&title);
header.append(&mode_selector(initial_mode, on_mode_change));
vbox.append(&header);
let banner_label = Label::new(None);
banner_label.set_wrap(true);
banner_label.set_xalign(0.0);
banner_label.set_hexpand(true);
let banner_dismiss = Button::from_icon_name("window-close-symbolic");
banner_dismiss.set_tooltip_text(Some("Dismiss"));
banner_dismiss.update_property(&[gtk4::accessible::Property::Label("Dismiss")]);
let banner_row = GBox::new(Orientation::Horizontal, 8);
banner_row.add_css_class("card");
banner_row.set_margin_top(4);
banner_row.set_margin_bottom(4);
banner_row.append(&banner_label);
banner_row.append(&banner_dismiss);
let banner_revealer = Revealer::new();
banner_revealer.set_transition_type(RevealerTransitionType::SlideDown);
banner_revealer.set_child(Some(&banner_row));
banner_revealer.set_reveal_child(false);
{
let banner_revealer = banner_revealer.clone();
banner_dismiss.connect_clicked(move |_| banner_revealer.set_reveal_child(false));
}
vbox.append(&banner_revealer);
vbox.append(&quick_fixes());
let troubleshoot_btn = Button::with_label("Something's wrong?");
troubleshoot_btn.set_halign(Align::Start);
{
let parent = parent_window.clone();
troubleshoot_btn.connect_clicked(move |_| troubleshoot_wizard::open(&parent));
}
vbox.append(&troubleshoot_btn);
// The live guided tour (ui::tour) is the real hands-on onboarding now —
// a static text preview of the same guide titles Learn already lists
// was a fossil of the old in-window wizard and just duplicated Learn's
// sidebar. A single re-entry point is all this tab needs.
let replay_btn = Button::with_label("Replay the tour");
replay_btn.set_halign(Align::Start);
{
let parent = parent_window.clone();
replay_btn.connect_clicked(move |_| {
crate::ui::tour::restart(&WidgetExt::display(&parent));
});
}
vbox.append(&replay_btn);
vbox.append(&keybind_viewer::build(binds));
let root = ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast();
Home { root, banner_revealer, banner_label }
}
fn quick_fixes() -> Widget {
let heading_box = GBox::new(Orientation::Vertical, 8);
let heading = Label::new(Some("Quick fixes"));
heading.add_css_class("section-header");
heading.set_xalign(0.0);
heading_box.append(&heading);
let flow = FlowBox::new();
flow.set_selection_mode(gtk4::SelectionMode::None);
flow.set_max_children_per_line(4);
flow.set_column_spacing(8);
flow.set_row_spacing(8);
let status = Label::new(None);
status.set_xalign(0.0);
status.add_css_class("dim-label");
for (label, command) in [
("Fix sound", FIX_SOUND),
("Reconnect Wi-Fi", FIX_WIFI),
("Update everything", FIX_UPDATE),
("Create backup", FIX_BACKUP),
] {
let btn = Button::with_label(label);
let command = command.to_string();
let status = status.clone();
let label = label.to_string();
btn.connect_clicked(move |_| {
status.set_label(&format!("{label}\u{2026}"));
let status = status.clone();
let label = label.clone();
crate::services::exec::run_reporting(&command, move |success| {
status.set_label(&if success { format!("{label}: done.") } else { format!("{label}: something went wrong.") });
});
});
flow.insert(&btn, -1);
}
heading_box.append(&flow);
heading_box.append(&status);
heading_box.upcast()
}
fn mode_selector(initial: Mode, on_change: impl Fn(Mode) + 'static) -> DropDown {
let labels = StringList::new(&["Normal", "Beginner", "Dad", "Compact"]);
let dropdown = DropDown::new(Some(labels), gtk4::Expression::NONE);
dropdown.set_selected(mode_index(initial));
dropdown.set_tooltip_text(Some("Display mode"));
dropdown.update_property(&[gtk4::accessible::Property::Label("Display mode")]);
dropdown.connect_selected_notify(move |dd| {
let mode = match dd.selected() {
1 => Mode::Beginner,
2 => Mode::Dad,
3 => Mode::Compact,
_ => Mode::Normal,
};
on_change(mode);
});
dropdown
}
fn mode_index(mode: Mode) -> u32 {
match mode {
Mode::Normal => 0,
Mode::Beginner => 1,
Mode::Dad => 2,
Mode::Compact => 3,
}
}