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:
commit
fea8f83204
50 changed files with 2403 additions and 0 deletions
17
Cargo.toml
Normal file
17
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
content/README.md
Normal file
28
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.
|
||||||
13
content/advanced/01-snapshots-and-rollback/content.md
Normal file
13
content/advanced/01-snapshots-and-rollback/content.md
Normal 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]
|
||||||
5
content/advanced/01-snapshots-and-rollback/meta.toml
Normal file
5
content/advanced/01-snapshots-and-rollback/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "Snapshots and rollback"
|
||||||
|
difficulty = "advanced"
|
||||||
|
tags = ["snapper", "snapshots", "rollback"]
|
||||||
|
related = []
|
||||||
|
estimated_minutes = 3
|
||||||
10
content/apps/01-notes-and-tasks/content.md
Normal file
10
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
content/apps/01-notes-and-tasks/meta.toml
Normal file
5
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
|
||||||
7
content/customization/01-bar-and-launcher/content.md
Normal file
7
content/customization/01-bar-and-launcher/content.md
Normal 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]
|
||||||
5
content/customization/01-bar-and-launcher/meta.toml
Normal file
5
content/customization/01-bar-and-launcher/meta.toml
Normal 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
|
||||||
9
content/daily-use/01-searching-your-files/content.md
Normal file
9
content/daily-use/01-searching-your-files/content.md
Normal 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]
|
||||||
5
content/daily-use/01-searching-your-files/meta.toml
Normal file
5
content/daily-use/01-searching-your-files/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "Searching your files"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["search", "files", "breadsearch"]
|
||||||
|
related = []
|
||||||
|
estimated_minutes = 2
|
||||||
7
content/daily-use/02-clipboard-history/content.md
Normal file
7
content/daily-use/02-clipboard-history/content.md
Normal 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]
|
||||||
5
content/daily-use/02-clipboard-history/meta.toml
Normal file
5
content/daily-use/02-clipboard-history/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "Clipboard history"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["clipboard", "breadclip"]
|
||||||
|
related = []
|
||||||
|
estimated_minutes = 1
|
||||||
10
content/getting-started/01-what-is-bos/content.beginner.md
Normal file
10
content/getting-started/01-what-is-bos/content.beginner.md
Normal 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]
|
||||||
21
content/getting-started/01-what-is-bos/content.md
Normal file
21
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]
|
||||||
5
content/getting-started/01-what-is-bos/meta.toml
Normal file
5
content/getting-started/01-what-is-bos/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "What is BOS?"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["overview", "welcome"]
|
||||||
|
related = ["02-essential-keybinds"]
|
||||||
|
estimated_minutes = 2
|
||||||
23
content/getting-started/02-essential-keybinds/content.md
Normal file
23
content/getting-started/02-essential-keybinds/content.md
Normal 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.
|
||||||
5
content/getting-started/02-essential-keybinds/meta.toml
Normal file
5
content/getting-started/02-essential-keybinds/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "The essential keybinds"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["keybinds", "hyprland"]
|
||||||
|
related = ["01-what-is-bos"]
|
||||||
|
estimated_minutes = 3
|
||||||
18
content/getting-started/03-personalizing-bos/content.md
Normal file
18
content/getting-started/03-personalizing-bos/content.md
Normal 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]
|
||||||
5
content/getting-started/03-personalizing-bos/meta.toml
Normal file
5
content/getting-started/03-personalizing-bos/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "Personalizing BOS"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["wallpaper", "theme", "customization"]
|
||||||
|
related = []
|
||||||
|
estimated_minutes = 3
|
||||||
13
content/troubleshooting/01-fixing-wifi/content.md
Normal file
13
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]
|
||||||
5
content/troubleshooting/01-fixing-wifi/meta.toml
Normal file
5
content/troubleshooting/01-fixing-wifi/meta.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
title = "Fixing Wi-Fi problems"
|
||||||
|
difficulty = "beginner"
|
||||||
|
tags = ["wifi", "network", "breadcrumbs"]
|
||||||
|
related = []
|
||||||
|
estimated_minutes = 2
|
||||||
35
content/troubleshooting/_symptoms/no-sound.toml
Normal file
35
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
|
||||||
25
content/troubleshooting/_symptoms/wifi-wont-connect.toml
Normal file
25
content/troubleshooting/_symptoms/wifi-wont-connect.toml
Normal 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
|
||||||
40
packaging/PKGBUILD
Normal file
40
packaging/PKGBUILD
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
|
pkgname=breadhelp
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Onboarding and help center for Bread OS"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/Breadway/bos"
|
||||||
|
license=('MIT')
|
||||||
|
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
||||||
|
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||||
|
# causing undefined-symbol errors. Disable LTO.
|
||||||
|
options=(!lto !debug)
|
||||||
|
depends=('gtk4' 'glib2' 'hicolor-icon-theme')
|
||||||
|
optdepends=(
|
||||||
|
'snapper: create-backup one-click fix'
|
||||||
|
)
|
||||||
|
makedepends=('rust' 'cargo')
|
||||||
|
source=("${pkgname}-${pkgver}.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
cargo build --release --locked -p breadhelp
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
cargo test --release --locked -p breadhelp
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
install -Dm755 target/release/breadhelp "${pkgdir}/usr/bin/breadhelp"
|
||||||
|
install -Dm644 packaging/arch/breadhelp/breadhelp.desktop \
|
||||||
|
"${pkgdir}/usr/share/applications/breadhelp.desktop"
|
||||||
|
install -d "${pkgdir}/usr/share/breadhelp"
|
||||||
|
cp -r breadhelp/content "${pkgdir}/usr/share/breadhelp/content"
|
||||||
|
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
|
}
|
||||||
9
packaging/breadhelp.desktop
Normal file
9
packaging/breadhelp.desktop
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=BOS Help
|
||||||
|
Comment=Your personal guide to the Bread desktop
|
||||||
|
Exec=breadhelp
|
||||||
|
Icon=help-browser
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Help;System;
|
||||||
|
StartupWMClass=com.breadway.breadhelp
|
||||||
37
src/cli.rs
Normal file
37
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
src/config.rs
Normal file
135
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
src/content/keybinds.rs
Normal file
126
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
src/content/markdown.rs
Normal file
230
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
src/content/meta.rs
Normal file
28
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
src/content/mod.rs
Normal file
145
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
src/content/troubleshoot.rs
Normal file
66
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
src/main.rs
Normal file
34
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
src/services/breadd.rs
Normal file
16
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
src/services/exec.rs
Normal file
28
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
src/services/hyprland.rs
Normal file
15
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
src/services/mod.rs
Normal file
3
src/services/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod breadd;
|
||||||
|
pub mod exec;
|
||||||
|
pub mod hyprland;
|
||||||
39
src/theme.rs
Normal file
39
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
src/ui/ask.rs
Normal file
173
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
src/ui/guide_view.rs
Normal file
20
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
src/ui/home.rs
Normal file
181
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
src/ui/keybind_viewer.rs
Normal file
115
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
src/ui/learn.rs
Normal file
100
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
src/ui/mod.rs
Normal file
10
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
src/ui/modes.rs
Normal file
25
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
src/ui/onboarding/mod.rs
Normal file
196
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
src/ui/onboarding/steps.rs
Normal file
51
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
src/ui/tabs.rs
Normal file
14
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
src/ui/troubleshoot_wizard.rs
Normal file
144
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
src/ui/window.rs
Normal file
142
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