bos/breadhelp/src/ui/troubleshoot_wizard.rs
Breadway 664298b6b4 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).
2026-07-05 09:16:14 +08:00

144 lines
5 KiB
Rust

//! Troubleshooting wizard: a small modal window that walks a symptom tree
//! (`content::troubleshoot`) — prompt as a heading, `options` as buttons,
//! a leaf's `fix` as a "Run fix" button + result status.
//!
//! Uses the `Rc<Self>` method-receiver pattern (rather than a boxed
//! recursive closure) so each button can re-render the same widget tree at
//! a different node — `self.clone()` (an `Rc` clone) moves cleanly into
//! GTK's `'static` signal closures.
use std::collections::HashMap;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Align, Box as GBox, Button, Label, Orientation, Window};
use crate::content::troubleshoot::{self, SymptomNode};
/// Curated symptom files with a human label — file stems aren't user-facing
/// strings, and not every `_symptoms/*.toml` necessarily belongs on the
/// top-level picker.
const SYMPTOMS: &[(&str, &str)] = &[("no-sound", "No sound"), ("wifi-wont-connect", "Wi-Fi won't connect")];
struct Wizard {
content: GBox,
trees: HashMap<String, Vec<SymptomNode>>,
}
pub fn open(parent: &impl IsA<gtk4::Window>) {
let window = Window::builder()
.transient_for(parent)
.modal(true)
.title("Something's wrong?")
.default_width(480)
.default_height(360)
.build();
let content = GBox::new(Orientation::Vertical, 12);
content.add_css_class("view-content");
let wizard = Rc::new(Wizard { content: content.clone(), trees: troubleshoot::load_all() });
wizard.render_picker();
window.set_child(Some(&content));
window.present();
}
impl Wizard {
fn clear(&self) {
while let Some(child) = self.content.first_child() {
self.content.remove(&child);
}
}
fn render_picker(self: &Rc<Self>) {
self.clear();
let title = Label::new(Some("What's wrong?"));
title.add_css_class("title");
title.set_xalign(0.0);
self.content.append(&title);
let mut any = false;
for (file, label) in SYMPTOMS {
if !self.trees.contains_key(*file) {
continue;
}
any = true;
let btn = Button::with_label(label);
btn.set_halign(Align::Start);
let this = self.clone();
let file = file.to_string();
btn.connect_clicked(move |_| this.render_node(&file, "start"));
self.content.append(&btn);
}
if !any {
let empty = Label::new(Some("No troubleshooting guides installed."));
empty.add_css_class("dim-label");
self.content.append(&empty);
}
}
fn render_node(self: &Rc<Self>, file: &str, id: &str) {
let Some(nodes) = self.trees.get(file) else {
self.render_picker();
return;
};
let Some(node) = nodes.iter().find(|n| n.id == id) else {
self.render_picker();
return;
};
self.clear();
let prompt = Label::new(Some(&node.prompt));
prompt.set_wrap(true);
prompt.set_xalign(0.0);
self.content.append(&prompt);
for opt in &node.options {
let btn = Button::with_label(&opt.label);
btn.set_halign(Align::Start);
let this = self.clone();
// `goto` is either a bare node id (same file) or
// "<file-stem>:<id>" to jump into a different symptom file.
let (goto_file, goto_id) = match opt.goto.split_once(':') {
Some((f, i)) => (f.to_string(), i.to_string()),
None => (file.to_string(), opt.goto.clone()),
};
btn.connect_clicked(move |_| this.render_node(&goto_file, &goto_id));
self.content.append(&btn);
}
if let Some(fix) = &node.fix {
let desc = Label::new(Some(&fix.description));
desc.set_xalign(0.0);
self.content.append(&desc);
let status = Label::new(None);
status.set_xalign(0.0);
status.add_css_class("dim-label");
let run_btn = Button::with_label("Run fix");
run_btn.add_css_class("suggested-action");
run_btn.set_halign(Align::Start);
let command = fix.command.clone();
let status_for_click = status.clone();
run_btn.connect_clicked(move |_| {
status_for_click.set_label("Running\u{2026}");
let status = status_for_click.clone();
crate::services::exec::run_reporting(&command, move |success| {
status.set_label(if success { "Done." } else { "Something went wrong — see the terminal for details." });
});
});
self.content.append(&run_btn);
self.content.append(&status);
}
let back_btn = Button::with_label("Start over");
back_btn.set_halign(Align::Start);
let this = self.clone();
back_btn.connect_clicked(move |_| this.render_picker());
self.content.append(&back_btn);
}
}