Extract bos-settings to its own repo; add breadhelp; JSON-driven Hyprland config

bos-settings moves to git.breadway.dev/Breadway/bos-settings (full history
preserved via git-filter-repo) so its release cadence is decoupled from
BOS's own. breadhelp takes its place as this repo's workspace member: a
GTK4 onboarding/help center replacing the old bos-welcome/bos-keybinds
bash scripts with searchable guides, an interactive keybind viewer
(sourced from the new keybinds.toml, not parsed out of hyprland.lua or
hardcoded), a troubleshooting wizard with one-click fixes, and a proper
first-run tour. bos-netcheck extracts bos-welcome's network-check half,
which still needs to run every login independent of breadhelp's own
first-run gating.

hyprland.lua's keybinds/settings/monitors/autostart are now JSON-driven
(binds.json/settings.json/monitors.json/autostart.json) with every
loader pcall-wrapped and falling back to hardcoded defaults per field on
bad or missing config, so bread* apps (bos-settings' new editors, and
breadhelp's keybind viewer) can read/write this config without ever
being able to leave the compositor unable to start.

CI's package.yml now builds breadhelp instead of bos-settings on tag
push; bos-settings needs its own equivalent workflow in its new repo
(not yet set up).
This commit is contained in:
Breadway 2026-07-05 09:16:14 +08:00
parent 434efcea22
commit 664298b6b4
105 changed files with 3280 additions and 5149 deletions

181
breadhelp/src/ui/home.rs Normal file
View file

@ -0,0 +1,181 @@
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, ContentStore};
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(
store: &ContentStore,
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);
let getting_started = store.guides_in("getting-started");
if !getting_started.is_empty() {
let heading = Label::new(Some("Getting Started"));
heading.add_css_class("section-header");
heading.set_xalign(0.0);
vbox.append(&heading);
for guide in getting_started.iter().take(3) {
let lbl = Label::new(Some(&format!("\u{2022} {}", guide.meta.title)));
lbl.set_xalign(0.0);
vbox.append(&lbl);
}
}
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,
}
}