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

17
breadhelp/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "breadhelp"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] }
glib = "0.22"
# Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/
# breadbox/breadpad load, so this looks like part of the same desktop.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Non-destructive state editing (mirrors bos-settings/src/config/mod.rs).
toml_edit = "0.22"
async-channel = "2"

View file

@ -0,0 +1,28 @@
# breadhelp content
Guides and troubleshooting data, installed to `/usr/share/breadhelp/content/`.
User overrides/additions live at `~/.local/share/breadhelp/content/` (same
shape; a colliding guide directory name wins wholesale over the system copy).
Categories are just top-level directories here — the Learn tab lists whatever
exists, so adding a category is a content change, not a code change:
```
getting-started/
daily-use/
customization/
apps/
troubleshooting/
_symptoms/ # troubleshooting wizard's symptom-tree *.toml files
advanced/
```
Each guide is a directory with:
- `meta.toml` — title, difficulty, tags, related guides, estimated time
- `content.md` — the guide body (constrained Markdown subset + `[Run]`/
`[Show Keybind]` tags — see `src/content/markdown.rs`)
- `content.beginner.md` — optional, used in Beginner/Dad mode instead of
`content.md` when present
Content lands starting at M2.

View file

@ -0,0 +1,13 @@
# Snapshots and rollback
Every package change snapshots your root filesystem automatically (snapper +
snap-pac). Nothing to configure — it just happens.
## Roll back
- From BOS Settings' Snapshots page, or
- Right from the GRUB menu at boot (pick an older snapshot to boot into).
## Take a manual snapshot before something risky
- [Run]snapper -c root create --description "manual snapshot"|Create a snapshot now[/Run]

View file

@ -0,0 +1,5 @@
title = "Snapshots and rollback"
difficulty = "advanced"
tags = ["snapper", "snapshots", "rollback"]
related = []
estimated_minutes = 3

View file

@ -0,0 +1,10 @@
# Notes and tasks
Two dedicated apps, so quick notes and real task tracking don't compete for
the same window.
- [Show Keybind]super + u[/Show Keybind] opens breadpad — a fast popup for jotting something down right now.
- [Show Keybind]super + m[/Show Keybind] opens breadman — reminders and a task list for things that need to stick around.
- [Run]breadpad|Open Notes[/Run]
- [Run]breadman|Open Tasks[/Run]

View file

@ -0,0 +1,5 @@
title = "Notes and tasks"
difficulty = "beginner"
tags = ["breadpad", "breadman", "notes"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,7 @@
# Customizing the bar and launcher
The bar (breadbar) and launcher (breadbox) both have their own settings
pages — position, modules, appearance.
- [Run]bos-settings --page breadbar|Open Bar Settings[/Run]
- [Run]bos-settings --page breadbox|Open Launcher Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Customizing the bar and launcher"
difficulty = "beginner"
tags = ["breadbar", "breadbox", "customization"]
related = ["01-what-is-bos"]
estimated_minutes = 2

View file

@ -0,0 +1,9 @@
# Searching your files
breadsearch indexes your files so you can find things instantly, without
digging through folders.
- Open it from BOS Settings, or run it directly.
- Type a few letters of a filename — results update as you type.
- [Run]breadsearch|Open File Search[/Run]

View file

@ -0,0 +1,5 @@
title = "Searching your files"
difficulty = "beginner"
tags = ["search", "files", "breadsearch"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,7 @@
# Clipboard history
BOS remembers what you've copied, not just the last thing.
- [Show Keybind]super + v[/Show Keybind] opens clipboard history — pick anything you've copied recently and paste it again.
- [Run]breadclip|Open Clipboard History[/Run]

View file

@ -0,0 +1,5 @@
title = "Clipboard history"
difficulty = "beginner"
tags = ["clipboard", "breadclip"]
related = []
estimated_minutes = 1

View file

@ -0,0 +1,10 @@
# Welcome!
This computer runs BOS — a friendly desktop built for you.
- Hold down the SUPER key (it looks like a little window logo) and press other keys to do things.
- [Show Keybind]super + /[/Show Keybind] always shows you every shortcut, so you never have to memorize anything.
- [Show Keybind]super + ,[/Show Keybind] opens Settings.
- If anything ever looks wrong, your computer saves a safety copy every time something changes — nothing is ever truly lost.
- [Run]bos-settings|Open Settings[/Run]

View file

@ -0,0 +1,21 @@
# Welcome to BOS
BOS (the Bread Operating System) is a complete Hyprland desktop with the
bread ecosystem preinstalled: a bar, a launcher, notes, Wi-Fi profiles, and a
settings app that needs no config files.
## The basics
- SUPER is the Windows/Cmd key — almost everything starts with it.
- [Show Keybind]super + /[/Show Keybind] shows the full keybind cheatsheet any time.
- [Show Keybind]super + ,[/Show Keybind] opens BOS Settings — configure the bar, launcher, Wi-Fi, notes, snapshots, and package updates all in one place.
- [Show Keybind]super + space[/Show Keybind] opens the app launcher (breadbox).
- [Show Keybind]super + return[/Show Keybind] opens a terminal.
## Safety net
Your system snapshots itself on every package change. If an update breaks
something, roll it back from BOS Settings or pick a snapshot right from the
GRUB menu at boot.
- [Run]bos-settings|Open BOS Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "What is BOS?"
difficulty = "beginner"
tags = ["overview", "welcome"]
related = ["02-essential-keybinds"]
estimated_minutes = 2

View file

@ -0,0 +1,23 @@
# The essential keybinds
Hyprland is keyboard-first. You don't need a mouse for any of this — but
nothing here is mandatory either, click around if you'd rather.
## Windows
- [Show Keybind]super + return[/Show Keybind] opens a terminal
- [Show Keybind]super + backspace[/Show Keybind] closes the focused window
- [Show Keybind]super + f[/Show Keybind] toggles fullscreen
- [Show Keybind]super + i[/Show Keybind] toggles floating
## Apps
- [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox)
- [Show Keybind]super + e[/Show Keybind] opens files
- [Show Keybind]super + b[/Show Keybind] opens the browser
- [Show Keybind]super + u[/Show Keybind] opens notes
## The full list
The Home tab's keybind viewer always has the complete, current list — this
guide only covers the ones you'll use every day.

View file

@ -0,0 +1,5 @@
title = "The essential keybinds"
difficulty = "beginner"
tags = ["keybinds", "hyprland"]
related = ["01-what-is-bos"]
estimated_minutes = 3

View file

@ -0,0 +1,18 @@
# Personalizing BOS
The whole desktop recolors itself from your wallpaper — bar, launcher,
notes, settings, and this help center all pick up the same palette
automatically.
## Change your wallpaper
- [Run]breadpaper|Open the wallpaper picker[/Run]
Picking a new wallpaper regenerates the color palette for every bread app —
no restart needed, everything live-reloads.
## Change the bar and launcher
Open BOS Settings for per-app options:
- [Run]bos-settings|Open BOS Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Personalizing BOS"
difficulty = "beginner"
tags = ["wallpaper", "theme", "customization"]
related = []
estimated_minutes = 3

View file

@ -0,0 +1,13 @@
# Fixing Wi-Fi problems
Most Wi-Fi issues clear up by toggling the radio off and back on.
- [Run]nmcli radio wifi off && sleep 1 && nmcli radio wifi on|Reconnect Wi-Fi[/Run]
For saved networks and profiles (home, work, away), use breadcrumbs:
- [Run]breadcrumbs|Manage Wi-Fi Profiles[/Run]
Or jump straight to the Network settings page:
- [Run]bos-settings --page network|Open Network Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Fixing Wi-Fi problems"
difficulty = "beginner"
tags = ["wifi", "network", "breadcrumbs"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,35 @@
# Symptom tree for the troubleshooting wizard — walked by
# src/ui/troubleshoot_wizard.rs via src/content/troubleshoot.rs.
[[node]]
id = "start"
prompt = "What's wrong with your sound?"
[[node.options]]
label = "No sound at all"
goto = "restart-audio"
[[node.options]]
label = "Wrong output device (e.g. HDMI instead of speakers)"
goto = "wrong-device"
[[node]]
id = "restart-audio"
prompt = "Restarting the audio service fixes this most of the time."
[node.fix]
description = "Restart PipeWire/WirePlumber"
command = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket"
requires_confirm = false
[[node]]
id = "wrong-device"
prompt = "Open Sound settings and pick the right output device from the list."
[[node.options]]
label = "Open Sound settings"
goto = "open-sound-settings"
[[node]]
id = "open-sound-settings"
prompt = "Opening BOS Settings' Sound page."
[node.fix]
description = "Open Sound settings"
command = "bos-settings --page sound"
requires_confirm = false

View file

@ -0,0 +1,25 @@
[[node]]
id = "start"
prompt = "What's happening with Wi-Fi?"
[[node.options]]
label = "It won't connect / keeps dropping"
goto = "toggle-radio"
[[node.options]]
label = "I need to manage saved networks or profiles"
goto = "breadcrumbs"
[[node]]
id = "toggle-radio"
prompt = "Toggling the Wi-Fi radio off and back on clears up most connection issues."
[node.fix]
description = "Reconnect Wi-Fi"
command = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on"
requires_confirm = false
[[node]]
id = "breadcrumbs"
prompt = "breadcrumbs manages your saved networks and profiles (home, work, away)."
[node.fix]
description = "Open breadcrumbs"
command = "breadcrumbs"
requires_confirm = false

37
breadhelp/src/cli.rs Normal file
View file

@ -0,0 +1,37 @@
//! argv handling for a `HANDLES_COMMAND_LINE` GApplication — every
//! invocation (including ones launched while breadhelp is already running,
//! forwarded over D-Bus to the primary instance) reaches
//! `connect_command_line`, so `--onboard` on a second launch re-triggers the
//! tour in the existing window instead of spawning a duplicate.
#[derive(Clone, Default)]
pub struct Action {
/// Force-restart the onboarding tour from step 0, regardless of whether
/// it was already completed or in progress.
pub force_onboard: bool,
/// Set by hyprland.lua's every-login autostart entry, as opposed to a
/// user explicitly running `breadhelp` or pressing SUPER+/. Lets
/// `present()` build the window (so the app is ready to respond the
/// instant it's needed) without popping it open when onboarding is
/// already done — autostart should only be visible on a genuine first
/// run, never on every subsequent login.
pub autostart: bool,
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
/// system event. Resolved to banner text by `services::breadd::resolve`.
pub suggest: Option<String>,
}
pub fn parse(args: &[std::ffi::OsString]) -> Action {
let mut action = Action::default();
let mut it = args.iter().skip(1);
while let Some(arg) = it.next() {
if arg == "--onboard" {
action.force_onboard = true;
} else if arg == "--autostart" {
action.autostart = true;
} else if arg == "--suggest" {
action.suggest = it.next().and_then(|s| s.to_str()).map(str::to_string);
}
}
action
}

135
breadhelp/src/config.rs Normal file
View file

@ -0,0 +1,135 @@
//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing, same
//! `load_doc`/`save_doc` discipline as `bos-settings/src/config/mod.rs`: a
//! missing file yields defaults, a file that exists but fails to parse is
//! backed up once before falling back, so a bad edit is always recoverable.
use std::path::{Path, PathBuf};
use toml_edit::{value, DocumentMut};
pub fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
}
fn state_path() -> PathBuf {
config_dir().join("breadhelp").join("state.toml")
}
fn load_doc(path: &Path) -> DocumentMut {
let Ok(text) = std::fs::read_to_string(path) else {
return DocumentMut::default();
};
match text.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
let backup = PathBuf::from(format!("{}.bak", path.display()));
eprintln!(
"breadhelp: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
}
fn save_doc(path: &Path, doc: &DocumentMut) {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("breadhelp: couldn't create {}: {e}", parent.display());
return;
}
}
if let Err(e) = std::fs::write(path, doc.to_string()) {
eprintln!("breadhelp: couldn't write {}: {e}", path.display());
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Normal,
Beginner,
Dad,
Compact,
}
impl Mode {
pub fn as_str(self) -> &'static str {
match self {
Mode::Normal => "normal",
Mode::Beginner => "beginner",
Mode::Dad => "dad",
Mode::Compact => "compact",
}
}
fn from_str(s: &str) -> Self {
match s {
"beginner" => Mode::Beginner,
"dad" => Mode::Dad,
"compact" => Mode::Compact,
_ => Mode::Normal,
}
}
/// Beginner/Dad pick `content.beginner.md` over `content.md` when present.
pub fn is_simplified(self) -> bool {
matches!(self, Mode::Beginner | Mode::Dad)
}
}
pub struct State {
doc: DocumentMut,
path: PathBuf,
}
impl State {
pub fn load() -> Self {
let path = state_path();
let doc = load_doc(&path);
Self { doc, path }
}
pub fn onboarding_completed(&self) -> bool {
self.doc
.get("onboarding")
.and_then(|t| t.get("completed"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn set_onboarding_completed(&mut self, completed: bool) {
self.doc["onboarding"]["completed"] = value(completed);
self.save();
}
pub fn onboarding_step(&self) -> i64 {
self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0)
}
pub fn set_onboarding_step(&mut self, step: i64) {
self.doc["onboarding"]["step"] = value(step);
self.save();
}
pub fn mode(&self) -> Mode {
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
Mode::from_str(s)
}
pub fn set_mode(&mut self, mode: Mode) {
self.doc["general"]["mode"] = value(mode.as_str());
self.save();
}
fn save(&self) {
save_doc(&self.path, &self.doc);
}
}

View file

@ -0,0 +1,126 @@
//! Loads `~/.config/hypr/binds.json` — the SAME structured data
//! `hyprland.lua` itself loads (via `scripts/input/{binds,keybinds}.lua`) to
//! generate the real Hyprland binds. Reading this directly, rather than a
//! separately-maintained cheatsheet, means the keybind viewer can never
//! drift from what's actually bound.
#[derive(Clone)]
pub struct Keybind {
pub combo: String,
pub action: String,
#[allow(dead_code)] // reserved for Ask-tab filtering/future grouping
pub category: String,
/// What the onboarding tour's "Try it" button runs for this bind.
pub demo_cmd: Option<String>,
/// The `<dispatcher>,<args>` half of the real `hyprctl keyword bind`
/// value (e.g. `"exec,breadbox"`) — only synthesized for `action: "exec"`
/// entries, since binds.json's other actions ("close", "fullscreen", ...)
/// are this app's own loader abstraction, not real Hyprland dispatcher
/// names, and there's no safe generic way to reconstruct one. Binds
/// without this simply don't offer a rebind button.
pub raw: Option<String>,
}
#[derive(serde::Deserialize)]
struct RawEntry {
action: String,
#[serde(default)]
command: Option<String>,
key: String,
#[serde(default)]
mods: Option<Vec<String>>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
category: String,
#[serde(default)]
demo_cmd: Option<String>,
}
#[derive(serde::Deserialize)]
struct BindsFile {
#[serde(default)]
default_mods: Vec<String>,
#[serde(default)]
bindings: Vec<RawEntry>,
}
fn binds_json_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
std::path::PathBuf::from(home).join(".config/hypr/binds.json")
}
pub fn load() -> Vec<Keybind> {
let Ok(text) = std::fs::read_to_string(binds_json_path()) else {
return Vec::new();
};
let file: BindsFile = match serde_json::from_str(&text) {
Ok(f) => f,
Err(e) => {
eprintln!("breadhelp: binds.json failed to parse: {e}");
return Vec::new();
}
};
let default_mods = if file.default_mods.is_empty() { vec!["SUPER".to_string()] } else { file.default_mods };
file.bindings
.into_iter()
.map(|entry| {
// An explicit "mods": [] (media keys) must NOT fall back to
// default_mods — only an omitted `mods` field should, matching
// scripts/input/keybinds.lua's own normalize_mods semantics.
let mods = entry.mods.unwrap_or_else(|| default_mods.clone());
let combo = if mods.is_empty() {
entry.key.to_lowercase()
} else {
format!("{} + {}", mods.join(" + ").to_lowercase(), entry.key.to_lowercase())
};
let raw = (entry.action == "exec").then(|| entry.command.clone()).flatten().map(|c| format!("exec,{c}"));
Keybind {
combo,
action: entry.label.unwrap_or(entry.action),
category: entry.category,
demo_cmd: entry.demo_cmd,
raw,
}
})
.collect()
}
/// Case-insensitive lookup by combo (e.g. "super + space") for `[Show
/// Keybind]` tags — falls back to `None` (rendered as raw text) rather than
/// panicking when a guide references a combo that doesn't exist.
pub fn find<'a>(binds: &'a [Keybind], combo: &str) -> Option<&'a Keybind> {
let norm = normalize(combo);
binds.iter().find(|b| normalize(&b.combo) == norm)
}
fn normalize(combo: &str) -> String {
combo.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase()
}
/// Best-effort conversion of a human combo ("super + shift + u") into
/// hyprctl's `<mods>_<mods>,<key>` form ("SUPER_SHIFT,U") for the temporary
/// rebind feature — not a full Hyprland key-name parser, just enough for the
/// common single-letter/named-key binds this viewer lists.
pub fn to_hypr_mods_key(combo: &str) -> String {
let parts: Vec<String> = combo
.split(|c: char| c == '+' || c.is_whitespace())
.filter(|s| !s.is_empty())
.map(normalize_token)
.collect();
match parts.split_last() {
None => String::new(),
Some((key, [])) => key.clone(),
Some((key, mods)) => format!("{},{}", mods.join("_"), key),
}
}
fn normalize_token(tok: &str) -> String {
match tok.to_lowercase().as_str() {
"space" => "SPACE".to_string(),
"return" | "enter" => "RETURN".to_string(),
"backspace" => "BACKSPACE".to_string(),
other => other.to_uppercase(),
}
}

View file

@ -0,0 +1,230 @@
//! Constrained-subset markdown parser + renderer for guide `content.md`.
//!
//! Deliberately not a full CommonMark parser (`pulldown-cmark`/`comrak`) and
//! deliberately not rendered as HTML in a WebKitGTK view: no sibling app in
//! this ecosystem embeds a browser engine, a full parser still wouldn't
//! understand the `[Run]`/`[Show Keybind]` tags so it buys nothing, and this
//! content is authored in-repo by BOS maintainers, not arbitrary user input —
//! a small subset is an acceptable authoring limit, not a security compromise.
//!
//! Supported: `#`/`##` headings, one-block-per-line paragraphs, `- ` bullets,
//! inline `**bold**`/`` `code` `` (rendered via GTK's own Pango markup), and
//! two custom tags: `[Run]<command>[/Run]` (optional `|Label` suffix) and
//! `[Show Keybind]<combo>[/Show Keybind]`.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation};
use super::keybinds::Keybind;
#[derive(Debug, Clone, PartialEq)]
pub enum Span {
Text(String),
Run { command: String, label: String },
Keybind(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Heading(u8, String),
Paragraph(Vec<Span>),
Bullet(Vec<Span>),
}
pub fn parse(src: &str) -> Vec<Block> {
let mut blocks = Vec::new();
for raw_line in src.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("## ") {
blocks.push(Block::Heading(2, rest.trim().to_string()));
} else if let Some(rest) = line.strip_prefix("# ") {
blocks.push(Block::Heading(1, rest.trim().to_string()));
} else if let Some(rest) = line.strip_prefix("- ") {
blocks.push(Block::Bullet(parse_spans(rest)));
} else {
blocks.push(Block::Paragraph(parse_spans(line)));
}
}
blocks
}
fn parse_spans(text: &str) -> Vec<Span> {
const RUN_OPEN: &str = "[Run]";
const RUN_CLOSE: &str = "[/Run]";
const KB_OPEN: &str = "[Show Keybind]";
const KB_CLOSE: &str = "[/Show Keybind]";
let mut spans = Vec::new();
let mut rest = text;
loop {
let run_idx = rest.find(RUN_OPEN);
let kb_idx = rest.find(KB_OPEN);
let next = match (run_idx, kb_idx) {
(Some(r), Some(k)) if r <= k => Some((r, true)),
(Some(_), Some(k)) => Some((k, false)),
(Some(r), None) => Some((r, true)),
(None, Some(k)) => Some((k, false)),
(None, None) => None,
};
let Some((idx, is_run)) = next else {
if !rest.is_empty() {
spans.push(Span::Text(rest.to_string()));
}
break;
};
if idx > 0 {
spans.push(Span::Text(rest[..idx].to_string()));
}
let (open_len, close_tag) = if is_run { (RUN_OPEN.len(), RUN_CLOSE) } else { (KB_OPEN.len(), KB_CLOSE) };
let after = &rest[idx + open_len..];
let Some(end) = after.find(close_tag) else {
// Unterminated tag — keep the literal text rather than dropping it.
spans.push(Span::Text(rest[idx..].to_string()));
break;
};
let inner = &after[..end];
if is_run {
let (command, label) = match inner.split_once('|') {
Some((c, l)) => (c.trim().to_string(), l.trim().to_string()),
None => (inner.trim().to_string(), inner.trim().to_string()),
};
spans.push(Span::Run { command, label });
} else {
spans.push(Span::Keybind(inner.trim().to_string()));
}
rest = &after[end + close_tag.len()..];
}
spans
}
pub fn render(blocks: &[Block], binds: &[Keybind]) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 10);
for block in blocks {
match block {
Block::Heading(level, text) => {
let lbl = Label::new(None);
let size = if *level == 1 { "large" } else { "medium" };
lbl.set_markup(&format!("<b><span size='{size}'>{}</span></b>", glib::markup_escape_text(text)));
lbl.set_xalign(0.0);
lbl.set_wrap(true);
vbox.append(&lbl);
}
Block::Paragraph(spans) => vbox.append(&render_spans(spans, binds)),
Block::Bullet(spans) => {
let row = GBox::new(Orientation::Horizontal, 6);
row.append(&Label::new(Some("\u{2022}")));
row.append(&render_spans(spans, binds));
vbox.append(&row);
}
}
}
vbox.upcast()
}
fn render_spans(spans: &[Span], binds: &[Keybind]) -> gtk4::Widget {
let row = GBox::new(Orientation::Horizontal, 6);
for span in spans {
match span {
Span::Text(t) => {
if t.trim().is_empty() {
continue;
}
let lbl = Label::new(None);
lbl.set_markup(&inline_markup(t));
lbl.set_wrap(true);
lbl.set_xalign(0.0);
row.append(&lbl);
}
Span::Run { command, label } => {
let btn = Button::with_label(label);
let cmd = command.clone();
btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
row.append(&btn);
}
Span::Keybind(combo) => {
let text = super::keybinds::find(binds, combo).map(|k| k.combo.clone()).unwrap_or_else(|| combo.clone());
let chip = Label::new(Some(&text));
chip.add_css_class("keybind-chip");
row.append(&chip);
}
}
}
row.upcast()
}
fn inline_markup(text: &str) -> String {
let escaped = glib::markup_escape_text(text).to_string();
let bolded = replace_delim(&escaped, "**", "<b>", "</b>");
replace_delim(&bolded, "`", "<tt>", "</tt>")
}
/// Alternates `open`/`close` between successive `delim`-separated segments —
/// an odd number of delimiters leaves a dangling tag, which GTK's markup
/// parser reports as a warning rather than crashing (acceptable: this only
/// runs over content authored in-repo, per the module's rationale above).
fn replace_delim(s: &str, delim: &str, open: &str, close: &str) -> String {
let mut parts = s.split(delim);
let mut out = parts.next().unwrap_or_default().to_string();
for (i, part) in parts.enumerate() {
out.push_str(if i % 2 == 0 { open } else { close });
out.push_str(part);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_heading_bullet_paragraph() {
let blocks = parse("# Title\n\nSome text.\n\n- a bullet\n");
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], Block::Heading(1, t) if t == "Title"));
assert!(matches!(&blocks[1], Block::Paragraph(_)));
assert!(matches!(&blocks[2], Block::Bullet(_)));
}
#[test]
fn parses_run_tag_with_label() {
let spans = parse_spans("Click [Run]bos-update|Update everything[/Run] to update.");
assert_eq!(spans.len(), 3);
assert!(matches!(&spans[0], Span::Text(t) if t == "Click "));
assert_eq!(
spans[1],
Span::Run { command: "bos-update".into(), label: "Update everything".into() }
);
assert!(matches!(&spans[2], Span::Text(t) if t == " to update."));
}
#[test]
fn parses_run_tag_without_label() {
let spans = parse_spans("[Run]breadpad[/Run]");
assert_eq!(spans, vec![Span::Run { command: "breadpad".into(), label: "breadpad".into() }]);
}
#[test]
fn parses_show_keybind_tag() {
let spans = parse_spans("Press [Show Keybind]super + space[/Show Keybind] to launch.");
assert_eq!(spans[1], Span::Keybind("super + space".into()));
}
#[test]
fn counts_multiple_tags_in_one_line() {
let spans =
parse_spans("[Run]a[/Run] then [Show Keybind]super+/[/Show Keybind] then [Run]b|B[/Run]");
let run_count = spans.iter().filter(|s| matches!(s, Span::Run { .. })).count();
let kb_count = spans.iter().filter(|s| matches!(s, Span::Keybind(_))).count();
assert_eq!(run_count, 2);
assert_eq!(kb_count, 1);
}
#[test]
fn unterminated_tag_falls_back_to_text() {
let spans = parse_spans("oops [Run]no closing tag");
assert_eq!(spans, vec![Span::Text("oops ".into()), Span::Text("[Run]no closing tag".into())]);
}
}

View file

@ -0,0 +1,28 @@
//! `meta.toml` schema — one per guide directory.
#[derive(serde::Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Difficulty {
#[default]
Beginner,
Intermediate,
Advanced,
}
#[derive(serde::Deserialize, Clone, Default)]
pub struct GuideMeta {
pub title: String,
#[serde(default)]
pub difficulty: Difficulty,
#[serde(default)]
pub tags: Vec<String>,
/// Guide dir-names in the same or another category. Unresolvable entries
/// are silently dropped when rendering "related guides" — never a crash.
#[serde(default)]
pub related: Vec<String>,
#[serde(default)]
pub estimated_minutes: u32,
/// Overrides the parent directory name as the guide's category, if set.
#[serde(default)]
pub category: Option<String>,
}

View file

@ -0,0 +1,145 @@
//! Guide content: `/usr/share/breadhelp/content/` (system, shipped by the
//! package) merged with `~/.local/share/breadhelp/content/` (user overrides/
//! additions). A guide directory name colliding between the two roots means
//! the user copy wins wholesale — no field-level merge, same "replace the
//! whole doc" discipline `bos-settings/src/config/mod.rs` uses for TOML, just
//! at the file-tree level.
pub mod keybinds;
pub mod markdown;
pub mod meta;
pub mod troubleshoot;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use meta::GuideMeta;
const SYSTEM_ROOT: &str = "/usr/share/breadhelp/content";
/// Categories are just top-level content directories — this only fixes the
/// *display order* of ones we know about; unrecognized directories still
/// show up (sorted alphabetically after these) instead of being hidden.
const CATEGORY_ORDER: &[&str] = &[
"getting-started",
"daily-use",
"customization",
"apps",
"troubleshooting",
"advanced",
];
pub struct Guide {
pub id: String,
pub category: String,
pub meta: GuideMeta,
pub dir: PathBuf,
}
pub struct ContentStore {
guides: Vec<Guide>,
}
fn user_content_root() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p.join("breadhelp/content");
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".local/share/breadhelp/content")
}
fn scan_root(root: &Path, out: &mut BTreeMap<(String, String), Guide>) {
let Ok(categories) = std::fs::read_dir(root) else {
return;
};
for cat_entry in categories.flatten() {
let cat_path = cat_entry.path();
if !cat_path.is_dir() {
continue;
}
let category = cat_entry.file_name().to_string_lossy().into_owned();
let Ok(guide_dirs) = std::fs::read_dir(&cat_path) else {
continue;
};
for guide_entry in guide_dirs.flatten() {
let guide_dir = guide_entry.path();
if !guide_dir.is_dir() {
continue;
}
let id = guide_entry.file_name().to_string_lossy().into_owned();
// troubleshooting/_symptoms holds wizard data, not guides.
if id == "_symptoms" {
continue;
}
let meta_path = guide_dir.join("meta.toml");
let Ok(meta_text) = std::fs::read_to_string(&meta_path) else {
continue;
};
let Ok(meta) = toml::from_str::<GuideMeta>(&meta_text) else {
eprintln!("breadhelp: {} failed to parse, skipping", meta_path.display());
continue;
};
let effective_category = meta.category.clone().unwrap_or_else(|| category.clone());
out.insert(
(effective_category.clone(), id.clone()),
Guide { id, category: effective_category, meta, dir: guide_dir },
);
}
}
}
fn category_rank(category: &str) -> usize {
CATEGORY_ORDER.iter().position(|c| *c == category).unwrap_or(CATEGORY_ORDER.len())
}
impl ContentStore {
pub fn load() -> Self {
let mut by_key = BTreeMap::new();
scan_root(Path::new(SYSTEM_ROOT), &mut by_key);
scan_root(&user_content_root(), &mut by_key);
let mut guides: Vec<Guide> = by_key.into_values().collect();
guides.sort_by(|a, b| {
(category_rank(&a.category), &a.category, &a.id).cmp(&(category_rank(&b.category), &b.category, &b.id))
});
Self { guides }
}
pub fn categories(&self) -> Vec<String> {
let mut seen = Vec::new();
for g in &self.guides {
if !seen.contains(&g.category) {
seen.push(g.category.clone());
}
}
seen
}
pub fn guides_in(&self, category: &str) -> Vec<&Guide> {
self.guides.iter().filter(|g| g.category == category).collect()
}
pub fn guide(&self, category: &str, id: &str) -> Option<&Guide> {
self.guides.iter().find(|g| g.category == category && g.id == id)
}
pub fn all(&self) -> &[Guide] {
&self.guides
}
/// The guide body for the given mode — `content.beginner.md` when in a
/// simplified mode and present, otherwise `content.md`. Never errors: a
/// missing file just renders as an empty guide rather than crashing.
pub fn body(&self, guide: &Guide, simplified: bool) -> String {
if simplified {
let beginner = guide.dir.join("content.beginner.md");
if let Ok(text) = std::fs::read_to_string(&beginner) {
return text;
}
}
std::fs::read_to_string(guide.dir.join("content.md")).unwrap_or_default()
}
}

View file

@ -0,0 +1,66 @@
//! Symptom-tree loader for the troubleshooting wizard (UI lands in M5;
//! this loader is defined now alongside the rest of the content system since
//! it's the same shape of problem as `keybinds.rs`/`meta.rs`).
use std::path::Path;
#[derive(serde::Deserialize, Clone)]
pub struct SymptomOption {
pub label: String,
/// Another node id, or "<file-stem>:<id>" to jump to a different file.
pub goto: String,
}
#[derive(serde::Deserialize, Clone)]
pub struct Fix {
pub description: String,
pub command: String,
#[serde(default)]
pub requires_confirm: bool,
}
#[derive(serde::Deserialize, Clone)]
pub struct SymptomNode {
pub id: String,
pub prompt: String,
#[serde(default)]
pub options: Vec<SymptomOption>,
#[serde(default)]
pub fix: Option<Fix>,
}
#[derive(serde::Deserialize)]
struct SymptomFile {
#[serde(rename = "node", default)]
nodes: Vec<SymptomNode>,
}
const SYSTEM_SYMPTOMS_DIR: &str = "/usr/share/breadhelp/content/troubleshooting/_symptoms";
/// Loads every `*.toml` file in the symptoms directory, keyed by file stem so
/// a `SymptomOption::goto` of `"no-sound:check-mute"` can be resolved.
pub fn load_all() -> std::collections::HashMap<String, Vec<SymptomNode>> {
let mut out = std::collections::HashMap::new();
let Ok(entries) = std::fs::read_dir(Path::new(SYSTEM_SYMPTOMS_DIR)) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
match toml::from_str::<SymptomFile>(&text) {
Ok(f) => {
out.insert(stem.to_string(), f.nodes);
}
Err(e) => eprintln!("breadhelp: {} failed to parse: {e}", path.display()),
}
}
out
}

34
breadhelp/src/main.rs Normal file
View file

@ -0,0 +1,34 @@
mod cli;
mod config;
mod content;
mod services;
mod theme;
mod ui;
use gtk4::gio::ApplicationFlags;
use gtk4::prelude::*;
fn main() {
// GApplication requires a dotted id (g_application_id_is_valid rejects a
// bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting
// it, which would break D-Bus single-instance activation). The Wayland
// app-id GTK reports is this exact string — the Hyprland window rule and
// .desktop's StartupWMClass must match it verbatim.
//
// HANDLES_COMMAND_LINE: `breadhelp --onboard` while already running must
// reach the running (primary) instance's argv, not just re-activate it
// with no arguments — that's what lets a second launch re-trigger the
// onboarding tour instead of only focusing the window.
let app = gtk4::Application::builder()
.application_id("com.breadway.breadhelp")
.flags(ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
app.connect_command_line(|app, cmdline| {
let action = cli::parse(&cmdline.arguments());
ui::window::present(app, action);
glib::ExitCode::SUCCESS
});
app.run();
}

View file

@ -0,0 +1,16 @@
//! Maps a `--suggest <id>` payload (sent by a breadd Lua module, e.g.
//! `breadhelp-suggest.lua` reacting to `bread.monitor.connected`) to Home
//! tab banner text.
pub struct Suggestion {
pub text: String,
}
pub fn resolve(id: &str) -> Option<Suggestion> {
match id {
"monitor-setup" => Some(Suggestion {
text: "New monitor detected — want to set up your display layout?".to_string(),
}),
_ => None,
}
}

View file

@ -0,0 +1,28 @@
use std::process::Command;
/// Fire-and-forget: runs `command` via a shell, matching the idiom already
/// used throughout bos-settings' views (`let _ = Command::new(...).spawn()`).
pub fn run(command: &str) {
if let Err(e) = Command::new("sh").arg("-c").arg(command).spawn() {
eprintln!("breadhelp: failed to run `{command}`: {e}");
}
}
/// Runs `command` on a background thread and delivers success/failure back
/// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the
/// process *launched*, not how it exited, and one-click fixes need to report
/// that back to the user (a toast), so this blocks a throwaway thread on
/// `Command::status()` instead of the UI thread.
pub fn run_reporting(command: &str, on_done: impl Fn(bool) + 'static) {
let command = command.to_string();
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let status = Command::new("sh").arg("-c").arg(&command).status();
let _ = tx.send_blocking(status.map(|s| s.success()).unwrap_or(false));
});
glib::MainContext::default().spawn_local(async move {
if let Ok(success) = rx.recv().await {
on_done(success);
}
});
}

View file

@ -0,0 +1,15 @@
use std::process::Command;
/// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately
/// and resets on the next Hyprland reload/login. `hyprland.lua` can't safely
/// be rewritten line-by-line by a program (hand-authored Lua, arbitrary
/// formatting), so this is the only rebind path — callers must label it
/// clearly as temporary in the UI.
///
/// `bind_value` is the full `<mods>,<key>,<dispatcher>,<args>` string, e.g.
/// `"SUPER,U,exec,breadpad"`.
pub fn rebind_temp(bind_value: &str) {
if let Err(e) = Command::new("hyprctl").args(["keyword", "bind", bind_value]).spawn() {
eprintln!("breadhelp: hyprctl rebind failed: {e}");
}
}

View file

@ -0,0 +1,3 @@
pub mod breadd;
pub mod exec;
pub mod hyprland;

39
breadhelp/src/theme.rs Normal file
View file

@ -0,0 +1,39 @@
//! Theming for breadhelp.
//!
//! Like bos-settings, breadhelp owns almost no styling: it loads the
//! ecosystem's shared stylesheet (generated by `bread-theme` from the pywal
//! palette) and layers only the layout rules specific to this app's tab
//! switcher + content shell on top.
use gtk4::CssProvider;
use std::cell::RefCell;
const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\
/* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\
label.keybind-chip { \
font-family: monospace; \
font-size: 11px; \
padding: 2px 6px; \
border-radius: 6px; \
background-color: alpha(@on-surface, 0.12); \
}\n\
/* Special modes (ui/modes.rs) — Beginner/Dad bump text and touch targets; \
Compact tightens padding. Content simplification (content.beginner.md) \
is a separate, content-level concern this is visual only. */\n\
.beginner-mode label, .dad-mode label { font-size: 15px; }\n\
.dad-mode label { font-size: 17px; }\n\
.dad-mode button { min-height: 44px; padding: 10px 18px; }\n\
.beginner-mode button { min-height: 36px; }\n\
.compact-mode .view-content { padding: 12px; }\n\
";
thread_local! {
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn load(_display: &gtk4::gdk::Display) {
bread_theme::gtk::apply_shared();
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
}

173
breadhelp/src/ui/ask.rs Normal file
View file

@ -0,0 +1,173 @@
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Box as GBox, Entry, Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Widget};
use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore};
use super::{guide_view, keybind_viewer};
struct CommandEntry {
name: &'static str,
description: &'static str,
}
/// A small, curated command gallery — not every `bread*` binary, just the
/// ones worth surfacing to someone searching for "what can I run".
const COMMANDS: &[CommandEntry] = &[
CommandEntry { name: "bos-update", description: "Update both the system (pacman) and the bread ecosystem (bakery) in one go." },
CommandEntry { name: "bos-settings", description: "Open BOS Settings." },
CommandEntry { name: "breadbox", description: "Open the app launcher." },
CommandEntry { name: "breadpad", description: "Open notes/reminders." },
CommandEntry { name: "breadman", description: "Open the notes/task manager." },
CommandEntry { name: "breadpaper", description: "Open the wallpaper picker." },
CommandEntry { name: "breadclip", description: "Open clipboard history." },
CommandEntry { name: "breadcrumbs", description: "Manage Wi-Fi profiles." },
];
enum Kind {
Guide { category: String, id: String },
Keybind(Keybind),
Command { name: &'static str, description: &'static str },
}
struct IndexEntry {
label: String,
haystack: String,
kind: Kind,
}
fn build_index(store: &ContentStore, binds: &[Keybind]) -> Vec<IndexEntry> {
let mut index = Vec::new();
for guide in store.all() {
let haystack = format!("{} {}", guide.meta.title, guide.meta.tags.join(" ")).to_lowercase();
index.push(IndexEntry {
label: guide.meta.title.clone(),
haystack,
kind: Kind::Guide { category: guide.category.clone(), id: guide.id.clone() },
});
}
for bind in binds {
let label = format!("{}{}", bind.combo, bind.action);
let haystack = label.to_lowercase();
index.push(IndexEntry { label, haystack, kind: Kind::Keybind(bind.clone()) });
}
for cmd in COMMANDS {
let haystack = format!("{} {}", cmd.name, cmd.description).to_lowercase();
index.push(IndexEntry {
label: cmd.name.to_string(),
haystack,
kind: Kind::Command { name: cmd.name, description: cmd.description },
});
}
index
}
pub fn build(store: Rc<ContentStore>, binds: Rc<Vec<Keybind>>, mode: Mode) -> Widget {
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(320);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let left = GBox::new(Orientation::Vertical, 8);
left.add_css_class("view-content");
let search = Entry::new();
search.set_placeholder_text(Some("Search guides, keybinds, commands\u{2026}"));
left.append(&search);
let results_list = ListBox::new();
results_list.set_selection_mode(SelectionMode::None);
results_list.set_activate_on_single_click(true);
results_list.add_css_class("boxed-list");
let results_scroller = ScrolledWindow::builder().child(&results_list).vexpand(true).build();
left.append(&results_scroller);
let detail = GBox::new(Orientation::Vertical, 10);
detail.add_css_class("view-content");
let detail_scroller = ScrolledWindow::builder().child(&detail).vexpand(true).hexpand(true).build();
let index = Rc::new(build_index(&store, &binds));
let populate: Rc<dyn Fn(&str)> = {
let results_list = results_list.clone();
let index = index.clone();
Rc::new(move |query: &str| {
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
let q = query.to_lowercase();
let matches = index
.iter()
.enumerate()
.filter(|(_, e)| q.is_empty() || e.haystack.contains(&q))
.take(40);
for (i, entry) in matches {
let row = ListBoxRow::new();
row.set_widget_name(&i.to_string());
let lbl = Label::new(Some(&entry.label));
lbl.set_xalign(0.0);
lbl.set_wrap(true);
lbl.set_margin_top(6);
lbl.set_margin_bottom(6);
lbl.set_margin_start(8);
lbl.set_margin_end(8);
row.set_child(Some(&lbl));
results_list.append(&row);
}
})
};
populate("");
search.connect_changed({
let populate = populate.clone();
move |entry| populate(&entry.text())
});
results_list.connect_row_activated(move |_, row| {
let Ok(i) = row.widget_name().parse::<usize>() else { return };
let Some(entry) = index.get(i) else { return };
while let Some(child) = detail.first_child() {
detail.remove(&child);
}
match &entry.kind {
Kind::Guide { category, id } => {
if let Some(guide) = store.guide(category, id) {
detail.append(&guide_view::build(&store, guide, mode.is_simplified(), &binds));
}
}
Kind::Keybind(bind) => {
let list = ListBox::new();
list.set_selection_mode(SelectionMode::None);
list.add_css_class("boxed-list");
list.append(&keybind_viewer::row(bind));
detail.append(&list);
}
Kind::Command { name, description } => {
let title = Label::new(Some(name));
title.add_css_class("title");
title.set_xalign(0.0);
detail.append(&title);
let desc = Label::new(Some(description));
desc.set_wrap(true);
desc.set_xalign(0.0);
detail.append(&desc);
let run_btn = gtk4::Button::with_label("Run");
run_btn.set_halign(gtk4::Align::Start);
let cmd = name.to_string();
run_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
detail.append(&run_btn);
}
}
});
hpaned.set_start_child(Some(&left));
hpaned.set_end_child(Some(&detail_scroller));
hpaned.upcast()
}

View file

@ -0,0 +1,20 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Label, Orientation, ScrolledWindow, Widget};
use crate::content::{keybinds::Keybind, markdown, ContentStore, Guide};
pub fn build(store: &ContentStore, guide: &Guide, simplified: bool, binds: &[Keybind]) -> Widget {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some(&guide.meta.title));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let body = store.body(guide, simplified);
let blocks = markdown::parse(&body);
vbox.append(&markdown::render(&blocks, binds));
ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast()
}

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,
}
}

View file

@ -0,0 +1,115 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, MenuButton, Orientation, Popover, ScrolledWindow, SelectionMode};
use crate::content::keybinds::{self, Keybind};
pub fn build(binds: &[Keybind]) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 6);
let header = Label::new(Some("Keybinds"));
header.add_css_class("title");
header.set_xalign(0.0);
vbox.append(&header);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::None);
list.add_css_class("boxed-list");
if binds.is_empty() {
let empty = Label::new(Some("No binds.json found at ~/.config/hypr/binds.json"));
empty.add_css_class("dim-label");
vbox.append(&empty);
return vbox.upcast();
}
for bind in binds {
list.append(&row(bind));
}
let scroller = ScrolledWindow::builder().min_content_height(320).vexpand(true).child(&list).build();
vbox.append(&scroller);
vbox.upcast()
}
pub fn row(bind: &Keybind) -> ListBoxRow {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 12);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let combo = Label::new(Some(&bind.combo));
combo.add_css_class("keybind-chip");
combo.set_width_chars(20);
combo.set_xalign(0.0);
hbox.append(&combo);
let action = Label::new(Some(&bind.action));
action.set_hexpand(true);
action.set_xalign(0.0);
action.set_wrap(true);
hbox.append(&action);
if let Some(demo) = &bind.demo_cmd {
let try_btn = Button::with_label("Try it");
let cmd = demo.clone();
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
hbox.append(&try_btn);
}
if let Some(raw) = bind.raw.clone() {
let rebind_btn = MenuButton::builder().icon_name("document-edit-symbolic").build();
rebind_btn.set_tooltip_text(Some("Temporary rebind"));
// Icon-only button — screen readers have nothing else to announce.
rebind_btn.update_property(&[gtk4::accessible::Property::Label("Temporary rebind")]);
rebind_btn.set_popover(Some(&rebind_popover(&raw)));
hbox.append(&rebind_btn);
}
row.set_child(Some(&hbox));
row
}
fn rebind_popover(raw: &str) -> Popover {
let popover = Popover::new();
let vbox = GBox::new(Orientation::Vertical, 8);
vbox.set_margin_top(10);
vbox.set_margin_bottom(10);
vbox.set_margin_start(10);
vbox.set_margin_end(10);
let hint = Label::new(Some("Temporary — resets on reload/login.\nEdit hyprland.lua for a permanent change."));
hint.add_css_class("dim-label");
hint.set_wrap(true);
vbox.append(&hint);
let entry = Entry::new();
entry.set_placeholder_text(Some("e.g. super + shift + u"));
vbox.append(&entry);
let apply = Button::with_label("Apply temporarily");
apply.add_css_class("suggested-action");
let raw = raw.to_string();
let popover_weak = popover.downgrade();
apply.connect_clicked(move |_| {
let combo = entry.text();
if combo.trim().is_empty() {
return;
}
let mods_key = keybinds::to_hypr_mods_key(&combo);
if mods_key.is_empty() {
return;
}
crate::services::hyprland::rebind_temp(&format!("{mods_key},{raw}"));
if let Some(p) = popover_weak.upgrade() {
p.popdown();
}
});
vbox.append(&apply);
popover.set_child(Some(&vbox));
popover
}

100
breadhelp/src/ui/learn.rs Normal file
View file

@ -0,0 +1,100 @@
use gtk4::prelude::*;
use gtk4::{Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Stack, Widget};
use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore};
use super::guide_view;
pub fn build(store: &ContentStore, binds: &[Keybind], mode: Mode) -> Widget {
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(220);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Single);
list.add_css_class("sidebar");
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
let mut first_page: Option<String> = None;
for category in store.categories() {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(&display_category(&category)));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
for guide in store.guides_in(&category) {
let page_name = format!("{}/{}", guide.category, guide.id);
let row = ListBoxRow::new();
row.set_widget_name(&page_name);
let lbl = Label::new(Some(&guide.meta.title));
lbl.set_xalign(0.0);
lbl.set_margin_top(4);
lbl.set_margin_bottom(4);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
let view = guide_view::build(store, guide, mode.is_simplified(), binds);
stack.add_named(&view, Some(&page_name));
if first_page.is_none() {
first_page = Some(page_name);
}
}
}
if let Some(first) = &first_page {
stack.set_visible_child_name(first);
let mut i = 0;
loop {
match list.row_at_index(i) {
None => break,
Some(row) if row.widget_name() == *first => {
list.select_row(Some(&row));
break;
}
_ => i += 1,
}
}
}
{
let stack = stack.clone();
list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let name = row.widget_name();
if !name.is_empty() {
stack.set_visible_child_name(&name);
}
}
});
}
let sidebar_scroller = ScrolledWindow::builder().child(&list).width_request(220).build();
hpaned.set_start_child(Some(&sidebar_scroller));
hpaned.set_end_child(Some(&stack));
hpaned.upcast()
}
fn display_category(id: &str) -> String {
id.split('-').map(capitalize).collect::<Vec<_>>().join(" ")
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}

10
breadhelp/src/ui/mod.rs Normal file
View file

@ -0,0 +1,10 @@
pub mod ask;
pub mod guide_view;
pub mod home;
pub mod keybind_viewer;
pub mod learn;
pub mod modes;
pub mod onboarding;
pub mod tabs;
pub mod troubleshoot_wizard;
pub mod window;

25
breadhelp/src/ui/modes.rs Normal file
View file

@ -0,0 +1,25 @@
use gtk4::prelude::*;
use gtk4::Widget;
use crate::config::Mode;
const ALL_CLASSES: &[&str] = &["beginner-mode", "dad-mode", "compact-mode"];
/// Swaps the mode CSS class on `root` — the actual layout rules (font-size
/// bumps, larger touch targets for Dad, tighter margins for Compact) live in
/// `theme.rs`'s `APP_CSS`, same "app owns only layout" split as
/// `bos-settings/src/theme.rs`. Applied to the window's root widget so it
/// affects every tab, not just Home.
pub fn apply(root: &impl IsA<Widget>, mode: Mode) {
let root = root.upcast_ref::<Widget>();
for class in ALL_CLASSES {
root.remove_css_class(class);
}
let class = match mode {
Mode::Normal => return,
Mode::Beginner => "beginner-mode",
Mode::Dad => "dad-mode",
Mode::Compact => "compact-mode",
};
root.add_css_class(class);
}

View file

@ -0,0 +1,196 @@
mod steps;
use std::cell::Cell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Align, Box as GBox, Button, Label, Orientation, Stack, StackTransitionType};
use crate::config::State;
use steps::Step;
/// The onboarding tour: a `Stack` of step pages (plain `gtk4::Stack` with a
/// slide transition, not `Adw.Carousel` — no libadwaita dependency exists
/// anywhere in this ecosystem, and introducing one for a 7-step tour isn't
/// justified) with a Back/Skip/Next nav bar and a step-dots progress
/// indicator. The caller places `root` in an `Overlay` above the normal
/// tabbed content and toggles its visibility; `goto` lets a forced restart
/// (`breadhelp --onboard` while already running) jump back to step 0 without
/// rebuilding the widget tree.
pub struct Onboarding {
pub root: GBox,
stack: Stack,
dots: Vec<Label>,
current: Rc<Cell<usize>>,
}
impl Onboarding {
pub fn goto(&self, step: usize) {
let steps = steps::steps();
let step = step.min(steps.len() - 1);
self.current.set(step);
self.stack.set_visible_child_name(&step.to_string());
update_dots(&self.dots, step);
}
}
pub fn build(on_finish: impl Fn() + 'static) -> Onboarding {
let root = GBox::new(Orientation::Vertical, 16);
root.add_css_class("view-content");
// GTK4's built-in "background" class paints the theme's opaque default
// background — needed since this sits in an `Overlay` above the tabs.
root.add_css_class("background");
root.set_vexpand(true);
root.set_hexpand(true);
let all_steps = steps::steps();
let stack = Stack::new();
stack.set_transition_type(StackTransitionType::SlideLeftRight);
stack.set_vexpand(true);
stack.set_valign(Align::Center);
for (i, step) in all_steps.iter().enumerate() {
stack.add_named(&build_step_page(step), Some(&i.to_string()));
}
let dots_row = GBox::new(Orientation::Horizontal, 6);
dots_row.set_halign(Align::Center);
let dots: Vec<Label> = all_steps
.iter()
.map(|_| {
let dot = Label::new(Some("\u{25CF}"));
dot.add_css_class("dim-label");
dots_row.append(&dot);
dot
})
.collect();
let start_step = (State::load().onboarding_step() as usize).min(all_steps.len() - 1);
let current = Rc::new(Cell::new(start_step));
stack.set_visible_child_name(&start_step.to_string());
update_dots(&dots, start_step);
let nav = GBox::new(Orientation::Horizontal, 8);
nav.set_halign(Align::Fill);
let skip_btn = Button::with_label("Skip");
let back_btn = Button::with_label("Back");
back_btn.set_sensitive(start_step > 0);
let next_btn = Button::with_label(if start_step + 1 == all_steps.len() { "Finish" } else { "Next" });
next_btn.add_css_class("suggested-action");
let spacer = GBox::new(Orientation::Horizontal, 0);
spacer.set_hexpand(true);
nav.append(&skip_btn);
nav.append(&spacer);
nav.append(&back_btn);
nav.append(&next_btn);
let on_finish = Rc::new(on_finish);
back_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let next_btn = next_btn.clone();
let back_btn = back_btn.clone();
let total = all_steps.len();
move |_| {
let i = current.get();
if i == 0 {
return;
}
let new = i - 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(new > 0);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
next_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let back_btn = back_btn.clone();
let next_btn = next_btn.clone();
let total = all_steps.len();
let on_finish = on_finish.clone();
move |_| {
let i = current.get();
if i + 1 >= total {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
return;
}
let new = i + 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(true);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
skip_btn.connect_clicked({
let on_finish = on_finish.clone();
move |_| {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
}
});
root.append(&stack);
root.append(&dots_row);
root.append(&nav);
Onboarding { root, stack, dots, current }
}
fn build_step_page(step: &Step) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.set_valign(Align::Center);
vbox.set_halign(Align::Center);
vbox.set_margin_start(40);
vbox.set_margin_end(40);
let title = Label::new(Some(step.title));
title.add_css_class("title");
title.set_wrap(true);
title.set_justify(gtk4::Justification::Center);
vbox.append(&title);
let body = Label::new(Some(step.body));
body.set_wrap(true);
body.set_justify(gtk4::Justification::Center);
body.set_max_width_chars(56);
vbox.append(&body);
if let Some(cmd) = step.demo_cmd {
let try_btn = Button::with_label("Try it");
let cmd = cmd.to_string();
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
try_btn.set_halign(Align::Center);
vbox.append(&try_btn);
}
vbox.upcast()
}
fn update_dots(dots: &[Label], active: usize) {
for (i, dot) in dots.iter().enumerate() {
if i == active {
dot.remove_css_class("dim-label");
} else {
dot.add_css_class("dim-label");
}
}
}

View file

@ -0,0 +1,51 @@
pub struct Step {
pub title: &'static str,
pub body: &'static str,
/// What the step's "Try it" button runs, if any.
pub demo_cmd: Option<&'static str>,
}
pub fn steps() -> &'static [Step] {
&[
Step {
title: "Welcome to BOS",
body: "You're running a complete Hyprland desktop with the bread ecosystem preinstalled. This quick tour covers the essentials — skip any time, and reopen it later with `breadhelp --onboard`.",
demo_cmd: None,
},
Step {
title: "The SUPER key",
body: "SUPER (the Windows/Cmd key) is how almost everything starts in BOS. Hold it down, then press another key.",
demo_cmd: None,
},
Step {
title: "Essential keybinds",
body: "SUPER + Space opens the app launcher. SUPER + Return opens a terminal. SUPER + / always shows the full keybind cheatsheet, right here in this app.",
demo_cmd: Some("breadbox"),
},
Step {
title: "Opening apps",
body: "breadbox (launcher), breadpad (notes), breadman (tasks), and BOS Settings are all one SUPER shortcut away.",
demo_cmd: Some("bos-settings"),
},
Step {
title: "Make it yours",
body: "Pick a wallpaper and the whole desktop recolors itself — bar, launcher, notes, and this help center all pick up the same palette.",
demo_cmd: Some("breadpaper"),
},
Step {
title: "Internet & updates",
body: "bos-update refreshes both the system and the bread ecosystem in one go.",
demo_cmd: Some("bos-update"),
},
Step {
title: "Backups & safety",
body: "Every package change snapshots your system automatically. If an update breaks something, roll it back from BOS Settings or pick a snapshot right from the GRUB menu at boot.",
demo_cmd: None,
},
Step {
title: "You're ready!",
body: "That's everything to get started. Press SUPER + / any time you forget a shortcut.",
demo_cmd: None,
},
]
}

14
breadhelp/src/ui/tabs.rs Normal file
View file

@ -0,0 +1,14 @@
use gtk4::prelude::*;
use gtk4::{Align, Stack, StackSwitcher};
/// Home/Learn/Ask tab switcher. Only 3 pages, so GTK's built-in
/// `StackSwitcher` is a better fit here than the grouped-`ListBox` sidebar
/// bos-settings uses for its ~19 settings pages.
pub fn build(stack: &Stack) -> StackSwitcher {
let switcher = StackSwitcher::new();
switcher.set_stack(Some(stack));
switcher.set_halign(Align::Center);
switcher.set_margin_top(8);
switcher.set_margin_bottom(8);
switcher
}

View file

@ -0,0 +1,144 @@
//! 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);
}
}

142
breadhelp/src/ui/window.rs Normal file
View file

@ -0,0 +1,142 @@
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Overlay, Stack, StackTransitionType};
use crate::cli::Action;
use crate::config::State;
use crate::content::{keybinds, ContentStore};
use super::home::Home;
use super::onboarding::{self, Onboarding};
use super::{ask, learn, modes, tabs};
const DEFAULT_TAB: &str = "home";
struct Handle {
window: ApplicationWindow,
onboarding: Onboarding,
home: Home,
}
thread_local! {
static HANDLE: RefCell<Option<Handle>> = const { RefCell::new(None) };
}
/// Called from every `command_line` invocation — including ones forwarded
/// over D-Bus to this primary instance by a second `breadhelp` launch — so
/// it must build the window at most once and reuse it thereafter.
pub fn present(app: &Application, action: Action) {
HANDLE.with(|cell| {
let mut just_built = false;
{
let mut cell_ref = cell.borrow_mut();
if cell_ref.is_none() {
*cell_ref = Some(build(app));
just_built = true;
}
}
let cell_ref = cell.borrow();
let handle = cell_ref.as_ref().unwrap();
if action.force_onboard {
let mut state = State::load();
state.set_onboarding_completed(false);
state.set_onboarding_step(0);
handle.onboarding.goto(0);
handle.onboarding.root.set_visible(true);
handle.window.present();
return;
}
if let Some(id) = &action.suggest {
if let Some(s) = crate::services::breadd::resolve(id) {
handle.home.set_suggestion(Some(&s.text));
}
// Only focus the window the first time this process builds it
// (i.e. breadhelp wasn't already running) — a background daemon
// event shouldn't steal focus from whatever the user is doing.
if just_built {
handle.window.present();
}
return;
}
// Every-login autostart builds the window (so the app is ready to
// respond to SUPER+/ instantly) but must only pop it open on a
// genuine first run — not on every subsequent login.
let silent_autostart = action.autostart && State::load().onboarding_completed();
if !silent_autostart {
handle.window.present();
}
});
}
fn build(app: &Application) -> Handle {
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Help")
.default_width(880)
.default_height(600)
.build();
crate::theme::load(&WidgetExt::display(&window));
let store = Rc::new(ContentStore::load());
let binds = Rc::new(keybinds::load());
let state = State::load();
modes::apply(&window, state.mode());
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.set_transition_type(StackTransitionType::SlideLeftRight);
let home = super::home::build(&store, &binds, &window, state.mode(), {
let window = window.clone();
move |mode| {
modes::apply(&window, mode);
let mut state = State::load();
state.set_mode(mode);
}
});
stack.add_titled(&home.root, Some("home"), "Home");
stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn");
stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask");
stack.set_visible_child_name(DEFAULT_TAB);
let switcher = tabs::build(&stack);
let content_vbox = GBox::new(Orientation::Vertical, 0);
content_vbox.append(&switcher);
content_vbox.append(&stack);
let overlay = Overlay::new();
overlay.set_child(Some(&content_vbox));
// The on_finish closure needs to hide the onboarding widget once it's
// built, but the widget doesn't exist until `onboarding::build` returns —
// so it closes over a cell filled in right after.
let hide_target: Rc<RefCell<Option<GBox>>> = Rc::new(RefCell::new(None));
let hide_target_for_closure = hide_target.clone();
let ob = onboarding::build(move || {
if let Some(w) = hide_target_for_closure.borrow().as_ref() {
w.set_visible(false);
}
});
*hide_target.borrow_mut() = Some(ob.root.clone());
ob.root.set_visible(!state.onboarding_completed());
overlay.add_overlay(&ob.root);
window.set_child(Some(&overlay));
// Deliberately not presented here — `present()` (the caller) decides
// whether this initial build should actually be shown (see
// `silent_autostart` above), so a from-cold every-login autostart with
// onboarding already complete builds a ready-but-hidden window instead
// of flashing it open.
Handle { window, onboarding: ob, home }
}