From fea8f83204737f11da10c357f14b5b52a538f9d3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 5 Jul 2026 09:16:14 +0800 Subject: [PATCH] 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). --- Cargo.toml | 17 ++ content/README.md | 28 +++ .../01-snapshots-and-rollback/content.md | 13 + .../01-snapshots-and-rollback/meta.toml | 5 + content/apps/01-notes-and-tasks/content.md | 10 + content/apps/01-notes-and-tasks/meta.toml | 5 + .../01-bar-and-launcher/content.md | 7 + .../01-bar-and-launcher/meta.toml | 5 + .../01-searching-your-files/content.md | 9 + .../01-searching-your-files/meta.toml | 5 + .../daily-use/02-clipboard-history/content.md | 7 + .../daily-use/02-clipboard-history/meta.toml | 5 + .../01-what-is-bos/content.beginner.md | 10 + .../getting-started/01-what-is-bos/content.md | 21 ++ .../getting-started/01-what-is-bos/meta.toml | 5 + .../02-essential-keybinds/content.md | 23 ++ .../02-essential-keybinds/meta.toml | 5 + .../03-personalizing-bos/content.md | 18 ++ .../03-personalizing-bos/meta.toml | 5 + .../troubleshooting/01-fixing-wifi/content.md | 13 + .../troubleshooting/01-fixing-wifi/meta.toml | 5 + .../troubleshooting/_symptoms/no-sound.toml | 35 +++ .../_symptoms/wifi-wont-connect.toml | 25 ++ packaging/PKGBUILD | 40 +++ packaging/breadhelp.desktop | 9 + src/cli.rs | 37 +++ src/config.rs | 135 ++++++++++ src/content/keybinds.rs | 126 ++++++++++ src/content/markdown.rs | 230 ++++++++++++++++++ src/content/meta.rs | 28 +++ src/content/mod.rs | 145 +++++++++++ src/content/troubleshoot.rs | 66 +++++ src/main.rs | 34 +++ src/services/breadd.rs | 16 ++ src/services/exec.rs | 28 +++ src/services/hyprland.rs | 15 ++ src/services/mod.rs | 3 + src/theme.rs | 39 +++ src/ui/ask.rs | 173 +++++++++++++ src/ui/guide_view.rs | 20 ++ src/ui/home.rs | 181 ++++++++++++++ src/ui/keybind_viewer.rs | 115 +++++++++ src/ui/learn.rs | 100 ++++++++ src/ui/mod.rs | 10 + src/ui/modes.rs | 25 ++ src/ui/onboarding/mod.rs | 196 +++++++++++++++ src/ui/onboarding/steps.rs | 51 ++++ src/ui/tabs.rs | 14 ++ src/ui/troubleshoot_wizard.rs | 144 +++++++++++ src/ui/window.rs | 142 +++++++++++ 50 files changed, 2403 insertions(+) create mode 100644 Cargo.toml create mode 100644 content/README.md create mode 100644 content/advanced/01-snapshots-and-rollback/content.md create mode 100644 content/advanced/01-snapshots-and-rollback/meta.toml create mode 100644 content/apps/01-notes-and-tasks/content.md create mode 100644 content/apps/01-notes-and-tasks/meta.toml create mode 100644 content/customization/01-bar-and-launcher/content.md create mode 100644 content/customization/01-bar-and-launcher/meta.toml create mode 100644 content/daily-use/01-searching-your-files/content.md create mode 100644 content/daily-use/01-searching-your-files/meta.toml create mode 100644 content/daily-use/02-clipboard-history/content.md create mode 100644 content/daily-use/02-clipboard-history/meta.toml create mode 100644 content/getting-started/01-what-is-bos/content.beginner.md create mode 100644 content/getting-started/01-what-is-bos/content.md create mode 100644 content/getting-started/01-what-is-bos/meta.toml create mode 100644 content/getting-started/02-essential-keybinds/content.md create mode 100644 content/getting-started/02-essential-keybinds/meta.toml create mode 100644 content/getting-started/03-personalizing-bos/content.md create mode 100644 content/getting-started/03-personalizing-bos/meta.toml create mode 100644 content/troubleshooting/01-fixing-wifi/content.md create mode 100644 content/troubleshooting/01-fixing-wifi/meta.toml create mode 100644 content/troubleshooting/_symptoms/no-sound.toml create mode 100644 content/troubleshooting/_symptoms/wifi-wont-connect.toml create mode 100644 packaging/PKGBUILD create mode 100644 packaging/breadhelp.desktop create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/content/keybinds.rs create mode 100644 src/content/markdown.rs create mode 100644 src/content/meta.rs create mode 100644 src/content/mod.rs create mode 100644 src/content/troubleshoot.rs create mode 100644 src/main.rs create mode 100644 src/services/breadd.rs create mode 100644 src/services/exec.rs create mode 100644 src/services/hyprland.rs create mode 100644 src/services/mod.rs create mode 100644 src/theme.rs create mode 100644 src/ui/ask.rs create mode 100644 src/ui/guide_view.rs create mode 100644 src/ui/home.rs create mode 100644 src/ui/keybind_viewer.rs create mode 100644 src/ui/learn.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/modes.rs create mode 100644 src/ui/onboarding/mod.rs create mode 100644 src/ui/onboarding/steps.rs create mode 100644 src/ui/tabs.rs create mode 100644 src/ui/troubleshoot_wizard.rs create mode 100644 src/ui/window.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f43db76 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/content/README.md b/content/README.md new file mode 100644 index 0000000..faa7601 --- /dev/null +++ b/content/README.md @@ -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. diff --git a/content/advanced/01-snapshots-and-rollback/content.md b/content/advanced/01-snapshots-and-rollback/content.md new file mode 100644 index 0000000..a250d00 --- /dev/null +++ b/content/advanced/01-snapshots-and-rollback/content.md @@ -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] diff --git a/content/advanced/01-snapshots-and-rollback/meta.toml b/content/advanced/01-snapshots-and-rollback/meta.toml new file mode 100644 index 0000000..70d051f --- /dev/null +++ b/content/advanced/01-snapshots-and-rollback/meta.toml @@ -0,0 +1,5 @@ +title = "Snapshots and rollback" +difficulty = "advanced" +tags = ["snapper", "snapshots", "rollback"] +related = [] +estimated_minutes = 3 diff --git a/content/apps/01-notes-and-tasks/content.md b/content/apps/01-notes-and-tasks/content.md new file mode 100644 index 0000000..25d8a4e --- /dev/null +++ b/content/apps/01-notes-and-tasks/content.md @@ -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] diff --git a/content/apps/01-notes-and-tasks/meta.toml b/content/apps/01-notes-and-tasks/meta.toml new file mode 100644 index 0000000..bc9e73e --- /dev/null +++ b/content/apps/01-notes-and-tasks/meta.toml @@ -0,0 +1,5 @@ +title = "Notes and tasks" +difficulty = "beginner" +tags = ["breadpad", "breadman", "notes"] +related = [] +estimated_minutes = 2 diff --git a/content/customization/01-bar-and-launcher/content.md b/content/customization/01-bar-and-launcher/content.md new file mode 100644 index 0000000..5b4b7bd --- /dev/null +++ b/content/customization/01-bar-and-launcher/content.md @@ -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] diff --git a/content/customization/01-bar-and-launcher/meta.toml b/content/customization/01-bar-and-launcher/meta.toml new file mode 100644 index 0000000..226ef70 --- /dev/null +++ b/content/customization/01-bar-and-launcher/meta.toml @@ -0,0 +1,5 @@ +title = "Customizing the bar and launcher" +difficulty = "beginner" +tags = ["breadbar", "breadbox", "customization"] +related = ["01-what-is-bos"] +estimated_minutes = 2 diff --git a/content/daily-use/01-searching-your-files/content.md b/content/daily-use/01-searching-your-files/content.md new file mode 100644 index 0000000..b0ee950 --- /dev/null +++ b/content/daily-use/01-searching-your-files/content.md @@ -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] diff --git a/content/daily-use/01-searching-your-files/meta.toml b/content/daily-use/01-searching-your-files/meta.toml new file mode 100644 index 0000000..11d0284 --- /dev/null +++ b/content/daily-use/01-searching-your-files/meta.toml @@ -0,0 +1,5 @@ +title = "Searching your files" +difficulty = "beginner" +tags = ["search", "files", "breadsearch"] +related = [] +estimated_minutes = 2 diff --git a/content/daily-use/02-clipboard-history/content.md b/content/daily-use/02-clipboard-history/content.md new file mode 100644 index 0000000..b65392b --- /dev/null +++ b/content/daily-use/02-clipboard-history/content.md @@ -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] diff --git a/content/daily-use/02-clipboard-history/meta.toml b/content/daily-use/02-clipboard-history/meta.toml new file mode 100644 index 0000000..3abcd7d --- /dev/null +++ b/content/daily-use/02-clipboard-history/meta.toml @@ -0,0 +1,5 @@ +title = "Clipboard history" +difficulty = "beginner" +tags = ["clipboard", "breadclip"] +related = [] +estimated_minutes = 1 diff --git a/content/getting-started/01-what-is-bos/content.beginner.md b/content/getting-started/01-what-is-bos/content.beginner.md new file mode 100644 index 0000000..ca01641 --- /dev/null +++ b/content/getting-started/01-what-is-bos/content.beginner.md @@ -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] diff --git a/content/getting-started/01-what-is-bos/content.md b/content/getting-started/01-what-is-bos/content.md new file mode 100644 index 0000000..26ccccf --- /dev/null +++ b/content/getting-started/01-what-is-bos/content.md @@ -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] diff --git a/content/getting-started/01-what-is-bos/meta.toml b/content/getting-started/01-what-is-bos/meta.toml new file mode 100644 index 0000000..b5d9c19 --- /dev/null +++ b/content/getting-started/01-what-is-bos/meta.toml @@ -0,0 +1,5 @@ +title = "What is BOS?" +difficulty = "beginner" +tags = ["overview", "welcome"] +related = ["02-essential-keybinds"] +estimated_minutes = 2 diff --git a/content/getting-started/02-essential-keybinds/content.md b/content/getting-started/02-essential-keybinds/content.md new file mode 100644 index 0000000..b5955ba --- /dev/null +++ b/content/getting-started/02-essential-keybinds/content.md @@ -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. diff --git a/content/getting-started/02-essential-keybinds/meta.toml b/content/getting-started/02-essential-keybinds/meta.toml new file mode 100644 index 0000000..eb3b47d --- /dev/null +++ b/content/getting-started/02-essential-keybinds/meta.toml @@ -0,0 +1,5 @@ +title = "The essential keybinds" +difficulty = "beginner" +tags = ["keybinds", "hyprland"] +related = ["01-what-is-bos"] +estimated_minutes = 3 diff --git a/content/getting-started/03-personalizing-bos/content.md b/content/getting-started/03-personalizing-bos/content.md new file mode 100644 index 0000000..b86bccf --- /dev/null +++ b/content/getting-started/03-personalizing-bos/content.md @@ -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] diff --git a/content/getting-started/03-personalizing-bos/meta.toml b/content/getting-started/03-personalizing-bos/meta.toml new file mode 100644 index 0000000..1325d32 --- /dev/null +++ b/content/getting-started/03-personalizing-bos/meta.toml @@ -0,0 +1,5 @@ +title = "Personalizing BOS" +difficulty = "beginner" +tags = ["wallpaper", "theme", "customization"] +related = [] +estimated_minutes = 3 diff --git a/content/troubleshooting/01-fixing-wifi/content.md b/content/troubleshooting/01-fixing-wifi/content.md new file mode 100644 index 0000000..cb14e0e --- /dev/null +++ b/content/troubleshooting/01-fixing-wifi/content.md @@ -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] diff --git a/content/troubleshooting/01-fixing-wifi/meta.toml b/content/troubleshooting/01-fixing-wifi/meta.toml new file mode 100644 index 0000000..124f122 --- /dev/null +++ b/content/troubleshooting/01-fixing-wifi/meta.toml @@ -0,0 +1,5 @@ +title = "Fixing Wi-Fi problems" +difficulty = "beginner" +tags = ["wifi", "network", "breadcrumbs"] +related = [] +estimated_minutes = 2 diff --git a/content/troubleshooting/_symptoms/no-sound.toml b/content/troubleshooting/_symptoms/no-sound.toml new file mode 100644 index 0000000..b67d8bb --- /dev/null +++ b/content/troubleshooting/_symptoms/no-sound.toml @@ -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 diff --git a/content/troubleshooting/_symptoms/wifi-wont-connect.toml b/content/troubleshooting/_symptoms/wifi-wont-connect.toml new file mode 100644 index 0000000..2e5c535 --- /dev/null +++ b/content/troubleshooting/_symptoms/wifi-wont-connect.toml @@ -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 diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..f454b9f --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: Breadway + +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" +} diff --git a/packaging/breadhelp.desktop b/packaging/breadhelp.desktop new file mode 100644 index 0000000..c41be36 --- /dev/null +++ b/packaging/breadhelp.desktop @@ -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 diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..70c6a04 --- /dev/null +++ b/src/cli.rs @@ -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, +} + +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 +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ad745c6 --- /dev/null +++ b/src/config.rs @@ -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::() { + 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); + } +} diff --git a/src/content/keybinds.rs b/src/content/keybinds.rs new file mode 100644 index 0000000..46ded38 --- /dev/null +++ b/src/content/keybinds.rs @@ -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, + /// The `,` 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, +} + +#[derive(serde::Deserialize)] +struct RawEntry { + action: String, + #[serde(default)] + command: Option, + key: String, + #[serde(default)] + mods: Option>, + #[serde(default)] + label: Option, + #[serde(default)] + category: String, + #[serde(default)] + demo_cmd: Option, +} + +#[derive(serde::Deserialize)] +struct BindsFile { + #[serde(default)] + default_mods: Vec, + #[serde(default)] + bindings: Vec, +} + +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 { + 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::().to_lowercase() +} + +/// Best-effort conversion of a human combo ("super + shift + u") into +/// hyprctl's `_,` 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 = 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(), + } +} diff --git a/src/content/markdown.rs b/src/content/markdown.rs new file mode 100644 index 0000000..332070b --- /dev/null +++ b/src/content/markdown.rs @@ -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][/Run]` (optional `|Label` suffix) and +//! `[Show Keybind][/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), + Bullet(Vec), +} + +pub fn parse(src: &str) -> Vec { + 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 { + 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!("{}", 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, "**", "", ""); + replace_delim(&bolded, "`", "", "") +} + +/// 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())]); + } +} diff --git a/src/content/meta.rs b/src/content/meta.rs new file mode 100644 index 0000000..905ce02 --- /dev/null +++ b/src/content/meta.rs @@ -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, + /// 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, + #[serde(default)] + pub estimated_minutes: u32, + /// Overrides the parent directory name as the guide's category, if set. + #[serde(default)] + pub category: Option, +} diff --git a/src/content/mod.rs b/src/content/mod.rs new file mode 100644 index 0000000..7f279a5 --- /dev/null +++ b/src/content/mod.rs @@ -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, +} + +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::(&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 = 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 { + 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() + } +} diff --git a/src/content/troubleshoot.rs b/src/content/troubleshoot.rs new file mode 100644 index 0000000..3613f84 --- /dev/null +++ b/src/content/troubleshoot.rs @@ -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 ":" 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, + #[serde(default)] + pub fix: Option, +} + +#[derive(serde::Deserialize)] +struct SymptomFile { + #[serde(rename = "node", default)] + nodes: Vec, +} + +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> { + 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::(&text) { + Ok(f) => { + out.insert(stem.to_string(), f.nodes); + } + Err(e) => eprintln!("breadhelp: {} failed to parse: {e}", path.display()), + } + } + out +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f4ba916 --- /dev/null +++ b/src/main.rs @@ -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(); +} diff --git a/src/services/breadd.rs b/src/services/breadd.rs new file mode 100644 index 0000000..a7c06d0 --- /dev/null +++ b/src/services/breadd.rs @@ -0,0 +1,16 @@ +//! Maps a `--suggest ` 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 { + match id { + "monitor-setup" => Some(Suggestion { + text: "New monitor detected — want to set up your display layout?".to_string(), + }), + _ => None, + } +} diff --git a/src/services/exec.rs b/src/services/exec.rs new file mode 100644 index 0000000..4bd87f9 --- /dev/null +++ b/src/services/exec.rs @@ -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); + } + }); +} diff --git a/src/services/hyprland.rs b/src/services/hyprland.rs new file mode 100644 index 0000000..99b2e69 --- /dev/null +++ b/src/services/hyprland.rs @@ -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 `,,,` 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}"); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..5087959 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod breadd; +pub mod exec; +pub mod hyprland; diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..5c9a3ad --- /dev/null +++ b/src/theme.rs @@ -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> = 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)); +} diff --git a/src/ui/ask.rs b/src/ui/ask.rs new file mode 100644 index 0000000..157ea82 --- /dev/null +++ b/src/ui/ask.rs @@ -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 { + 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, binds: Rc>, 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 = { + 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::() 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() +} diff --git a/src/ui/guide_view.rs b/src/ui/guide_view.rs new file mode 100644 index 0000000..42da4c7 --- /dev/null +++ b/src/ui/guide_view.rs @@ -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() +} diff --git a/src/ui/home.rs b/src/ui/home.rs new file mode 100644 index 0000000..256fea6 --- /dev/null +++ b/src/ui/home.rs @@ -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, + } +} diff --git a/src/ui/keybind_viewer.rs b/src/ui/keybind_viewer.rs new file mode 100644 index 0000000..3cb2f14 --- /dev/null +++ b/src/ui/keybind_viewer.rs @@ -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 +} diff --git a/src/ui/learn.rs b/src/ui/learn.rs new file mode 100644 index 0000000..84d4623 --- /dev/null +++ b/src/ui/learn.rs @@ -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 = 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::>().join(" ") +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..87c82db --- /dev/null +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/modes.rs b/src/ui/modes.rs new file mode 100644 index 0000000..a18f921 --- /dev/null +++ b/src/ui/modes.rs @@ -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, mode: Mode) { + let root = root.upcast_ref::(); + 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); +} diff --git a/src/ui/onboarding/mod.rs b/src/ui/onboarding/mod.rs new file mode 100644 index 0000000..fca6c57 --- /dev/null +++ b/src/ui/onboarding/mod.rs @@ -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