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:
parent
434efcea22
commit
664298b6b4
105 changed files with 3280 additions and 5149 deletions
17
breadhelp/Cargo.toml
Normal file
17
breadhelp/Cargo.toml
Normal 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"
|
||||
28
breadhelp/content/README.md
Normal file
28
breadhelp/content/README.md
Normal 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.
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Snapshots and rollback"
|
||||
difficulty = "advanced"
|
||||
tags = ["snapper", "snapshots", "rollback"]
|
||||
related = []
|
||||
estimated_minutes = 3
|
||||
10
breadhelp/content/apps/01-notes-and-tasks/content.md
Normal file
10
breadhelp/content/apps/01-notes-and-tasks/content.md
Normal 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]
|
||||
5
breadhelp/content/apps/01-notes-and-tasks/meta.toml
Normal file
5
breadhelp/content/apps/01-notes-and-tasks/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
title = "Notes and tasks"
|
||||
difficulty = "beginner"
|
||||
tags = ["breadpad", "breadman", "notes"]
|
||||
related = []
|
||||
estimated_minutes = 2
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Customizing the bar and launcher"
|
||||
difficulty = "beginner"
|
||||
tags = ["breadbar", "breadbox", "customization"]
|
||||
related = ["01-what-is-bos"]
|
||||
estimated_minutes = 2
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Searching your files"
|
||||
difficulty = "beginner"
|
||||
tags = ["search", "files", "breadsearch"]
|
||||
related = []
|
||||
estimated_minutes = 2
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Clipboard history"
|
||||
difficulty = "beginner"
|
||||
tags = ["clipboard", "breadclip"]
|
||||
related = []
|
||||
estimated_minutes = 1
|
||||
|
|
@ -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]
|
||||
21
breadhelp/content/getting-started/01-what-is-bos/content.md
Normal file
21
breadhelp/content/getting-started/01-what-is-bos/content.md
Normal 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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "What is BOS?"
|
||||
difficulty = "beginner"
|
||||
tags = ["overview", "welcome"]
|
||||
related = ["02-essential-keybinds"]
|
||||
estimated_minutes = 2
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "The essential keybinds"
|
||||
difficulty = "beginner"
|
||||
tags = ["keybinds", "hyprland"]
|
||||
related = ["01-what-is-bos"]
|
||||
estimated_minutes = 3
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Personalizing BOS"
|
||||
difficulty = "beginner"
|
||||
tags = ["wallpaper", "theme", "customization"]
|
||||
related = []
|
||||
estimated_minutes = 3
|
||||
13
breadhelp/content/troubleshooting/01-fixing-wifi/content.md
Normal file
13
breadhelp/content/troubleshooting/01-fixing-wifi/content.md
Normal 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]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title = "Fixing Wi-Fi problems"
|
||||
difficulty = "beginner"
|
||||
tags = ["wifi", "network", "breadcrumbs"]
|
||||
related = []
|
||||
estimated_minutes = 2
|
||||
35
breadhelp/content/troubleshooting/_symptoms/no-sound.toml
Normal file
35
breadhelp/content/troubleshooting/_symptoms/no-sound.toml
Normal 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
|
||||
|
|
@ -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
37
breadhelp/src/cli.rs
Normal 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
135
breadhelp/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
126
breadhelp/src/content/keybinds.rs
Normal file
126
breadhelp/src/content/keybinds.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
230
breadhelp/src/content/markdown.rs
Normal file
230
breadhelp/src/content/markdown.rs
Normal 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())]);
|
||||
}
|
||||
}
|
||||
28
breadhelp/src/content/meta.rs
Normal file
28
breadhelp/src/content/meta.rs
Normal 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>,
|
||||
}
|
||||
145
breadhelp/src/content/mod.rs
Normal file
145
breadhelp/src/content/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
66
breadhelp/src/content/troubleshoot.rs
Normal file
66
breadhelp/src/content/troubleshoot.rs
Normal 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
34
breadhelp/src/main.rs
Normal 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();
|
||||
}
|
||||
16
breadhelp/src/services/breadd.rs
Normal file
16
breadhelp/src/services/breadd.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
28
breadhelp/src/services/exec.rs
Normal file
28
breadhelp/src/services/exec.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
15
breadhelp/src/services/hyprland.rs
Normal file
15
breadhelp/src/services/hyprland.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
3
breadhelp/src/services/mod.rs
Normal file
3
breadhelp/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod breadd;
|
||||
pub mod exec;
|
||||
pub mod hyprland;
|
||||
39
breadhelp/src/theme.rs
Normal file
39
breadhelp/src/theme.rs
Normal 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: >k4::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
173
breadhelp/src/ui/ask.rs
Normal 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()
|
||||
}
|
||||
20
breadhelp/src/ui/guide_view.rs
Normal file
20
breadhelp/src/ui/guide_view.rs
Normal 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
181
breadhelp/src/ui/home.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
115
breadhelp/src/ui/keybind_viewer.rs
Normal file
115
breadhelp/src/ui/keybind_viewer.rs
Normal 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
100
breadhelp/src/ui/learn.rs
Normal 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
10
breadhelp/src/ui/mod.rs
Normal 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
25
breadhelp/src/ui/modes.rs
Normal 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);
|
||||
}
|
||||
196
breadhelp/src/ui/onboarding/mod.rs
Normal file
196
breadhelp/src/ui/onboarding/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
51
breadhelp/src/ui/onboarding/steps.rs
Normal file
51
breadhelp/src/ui/onboarding/steps.rs
Normal 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
14
breadhelp/src/ui/tabs.rs
Normal 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
|
||||
}
|
||||
144
breadhelp/src/ui/troubleshoot_wizard.rs
Normal file
144
breadhelp/src/ui/troubleshoot_wizard.rs
Normal 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
142
breadhelp/src/ui/window.rs
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue