Extract breadhelp to its own repo (git.breadway.dev/Breadway/breadhelp)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s

Mirrors the bos-settings extraction (664298b): a breadhelp release no
longer requires a v* tag on the whole bos monorepo, which was
indistinguishable from an actual BOS-version release tag. Source lives
at ~/Projects/breadhelp now, full history preserved via git-filter-repo.

Root Cargo.toml/Cargo.lock and .forgejo/workflows/package.yml removed
too — breadhelp was the sole workspace member and the sole thing that
workflow built.
This commit is contained in:
Breadway 2026-07-15 19:20:23 +08:00
parent b2f0b2a500
commit 99cc0a4717
57 changed files with 0 additions and 4333 deletions

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="breadhelp-${VERSION}/" HEAD \
> packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/breadhelp/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/breadhelp/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch/breadhelp && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch/breadhelp -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

1108
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
[workspace]
members = ["breadhelp"]
resolver = "2"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +0,0 @@
# Live guided tour steps, walked by ui::tour. Phase A validation subset —
# just enough to prove the spotlight/must-do engine end-to-end (breadbar's
# plain observe-only highlight, breadbox's real openlayer detection) before
# authoring the full app-by-app tour. See breadhelp-tour.lua for the
# `--tour-event` ids this matches against.
[[step]]
id = "welcome"
title = "Welcome to BOS"
body = "This is a quick, hands-on tour of your desktop. Instead of just describing things, we'll point at the real thing on screen as we go. Press Next when you're ready."
[[step]]
id = "breadbar"
title = "Breadbar"
body = "This bar across the top of your screen always shows your workspace, the time, and quick status for network, volume, and battery. Click any icon on it to see more."
target_namespace = "breadbar"
[[step]]
id = "breadbox"
title = "Opening apps"
body = "Everything in BOS opens from here. Press [Show Keybind]super + space[/Show Keybind] to open breadbox, then start typing an app's name and press Enter to launch it."
target_namespace = "breadbox"
launch = "breadbox"
success_event = "layer-closed:breadbox"

View file

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

View file

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

View file

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

View file

@ -1,25 +0,0 @@
[[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

View file

@ -1,45 +0,0 @@
//! 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>,
/// From `breadhelp-tour.lua` forwarding a bread/Hyprland event (window
/// opened, layer opened, workspace changed, or a rebind-chained ping) as
/// a candidate "the user just did the thing" signal for the live tour.
/// Only acted on if a tour is currently waiting for this exact id —
/// see the crash-safety note on `ui::tour`.
pub tour_event: 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);
} else if arg == "--tour-event" {
action.tour_event = it.next().and_then(|s| s.to_str()).map(str::to_string);
}
}
action
}

View file

@ -1,161 +0,0 @@
//! `~/.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();
}
/// Set the instant before `services::hyprland::rebind_temp` is called for
/// a tour step with no compositor-observable signal (e.g. the screenshot
/// step), cleared the instant after reverting. If breadhelp is killed
/// mid-step, this is how the next launch knows a keybind was left
/// pointing at a chained `--tour-event` ping and self-heals it before
/// the user can be surprised by a stray tour popup — see `ui::tour`.
pub fn pending_rebind(&self) -> Option<(String, String)> {
let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string();
let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string();
Some((key, original))
}
pub fn set_pending_rebind(&mut self, key: &str, original_bind_value: &str) {
self.doc["tour"]["pending_rebind_key"] = value(key);
self.doc["tour"]["pending_rebind_original"] = value(original_bind_value);
self.save();
}
pub fn clear_pending_rebind(&mut self) {
if let Some(table) = self.doc.get_mut("tour").and_then(|t| t.as_table_mut()) {
table.remove("pending_rebind_key");
table.remove("pending_rebind_original");
}
self.save();
}
pub fn mode(&self) -> Mode {
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
Mode::from_str(s)
}
pub fn set_mode(&mut self, mode: Mode) {
self.doc["general"]["mode"] = value(mode.as_str());
self.save();
}
fn save(&self) {
save_doc(&self.path, &self.doc);
}
}

View file

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

View file

@ -1,237 +0,0 @@
//! 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);
// `set_wrap` alone only wraps once something else constrains
// the label's allocated width (true inside guide_view's fixed-
// width scrolled pane) — a freestanding top-level window (the
// tour callout) has nothing to allocate against, so without
// this a long paragraph reports its unwrapped natural width
// and the whole window balloons to fit it.
lbl.set_max_width_chars(48);
lbl.set_xalign(0.0);
row.append(&lbl);
}
Span::Run { command, label } => {
let btn = Button::with_label(label);
let cmd = command.clone();
btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
row.append(&btn);
}
Span::Keybind(combo) => {
let text = super::keybinds::find(binds, combo).map(|k| k.combo.clone()).unwrap_or_else(|| combo.clone());
let chip = Label::new(Some(&text));
chip.add_css_class("keybind-chip");
row.append(&chip);
}
}
}
row.upcast()
}
fn inline_markup(text: &str) -> String {
let escaped = glib::markup_escape_text(text).to_string();
let bolded = replace_delim(&escaped, "**", "<b>", "</b>");
replace_delim(&bolded, "`", "<tt>", "</tt>")
}
/// Alternates `open`/`close` between successive `delim`-separated segments —
/// an odd number of delimiters leaves a dangling tag, which GTK's markup
/// parser reports as a warning rather than crashing (acceptable: this only
/// runs over content authored in-repo, per the module's rationale above).
fn replace_delim(s: &str, delim: &str, open: &str, close: &str) -> String {
let mut parts = s.split(delim);
let mut out = parts.next().unwrap_or_default().to_string();
for (i, part) in parts.enumerate() {
out.push_str(if i % 2 == 0 { open } else { close });
out.push_str(part);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_heading_bullet_paragraph() {
let blocks = parse("# Title\n\nSome text.\n\n- a bullet\n");
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], Block::Heading(1, t) if t == "Title"));
assert!(matches!(&blocks[1], Block::Paragraph(_)));
assert!(matches!(&blocks[2], Block::Bullet(_)));
}
#[test]
fn parses_run_tag_with_label() {
let spans = parse_spans("Click [Run]bos-update|Update everything[/Run] to update.");
assert_eq!(spans.len(), 3);
assert!(matches!(&spans[0], Span::Text(t) if t == "Click "));
assert_eq!(
spans[1],
Span::Run { command: "bos-update".into(), label: "Update everything".into() }
);
assert!(matches!(&spans[2], Span::Text(t) if t == " to update."));
}
#[test]
fn parses_run_tag_without_label() {
let spans = parse_spans("[Run]breadpad[/Run]");
assert_eq!(spans, vec![Span::Run { command: "breadpad".into(), label: "breadpad".into() }]);
}
#[test]
fn parses_show_keybind_tag() {
let spans = parse_spans("Press [Show Keybind]super + space[/Show Keybind] to launch.");
assert_eq!(spans[1], Span::Keybind("super + space".into()));
}
#[test]
fn counts_multiple_tags_in_one_line() {
let spans =
parse_spans("[Run]a[/Run] then [Show Keybind]super+/[/Show Keybind] then [Run]b|B[/Run]");
let run_count = spans.iter().filter(|s| matches!(s, Span::Run { .. })).count();
let kb_count = spans.iter().filter(|s| matches!(s, Span::Keybind(_))).count();
assert_eq!(run_count, 2);
assert_eq!(kb_count, 1);
}
#[test]
fn unterminated_tag_falls_back_to_text() {
let spans = parse_spans("oops [Run]no closing tag");
assert_eq!(spans, vec![Span::Text("oops ".into()), Span::Text("[Run]no closing tag".into())]);
}
}

View file

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

View file

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

View file

@ -1,101 +0,0 @@
//! Live tour step loader — same shape of problem as `troubleshoot.rs`'s
//! symptom trees: TOML data, not hardcoded Rust, so the tour can be edited
//! without a rebuild. Fields are flat optionals rather than a tagged enum in
//! the TOML itself (mirroring `SymptomOption::goto`'s plain-string encoding)
//! to keep hand-authored `onboarding.toml` simple; `target()`/`success()`
//! turn that into the richer shape `ui::tour` actually wants to match on.
use std::path::Path;
#[derive(Clone)]
pub enum Target {
/// A `gtk4-layer-shell` surface identified by namespace, e.g. "breadbox".
Namespace(String),
/// A plain toplevel window identified by its Wayland app-id/class.
WindowClass(String),
/// Pure-concept step — full-screen dim, centered callout, no spotlight.
None,
}
#[derive(Clone)]
pub enum Success {
/// Auto-advance when a `--tour-event <id>` matching this arrives.
Event(String),
/// Auto-advance after this many seconds if no event arrives first.
Timeout(u64),
/// Next button only — no event can signal this step's completion.
Manual,
}
#[derive(serde::Deserialize, Clone)]
pub struct Step {
pub id: String,
pub title: String,
pub body: String,
#[serde(default)]
pub target_namespace: Option<String>,
#[serde(default)]
pub target_window_class: Option<String>,
/// Shell command run when the step starts, e.g. launching breadbox so
/// there's something to highlight. Skipped if the target is already
/// resolvable — several bread apps toggle-close on a second invocation
/// of the same command, so firing this unconditionally could close an
/// already-open instance instead of opening one.
#[serde(default)]
pub launch: Option<String>,
#[serde(default)]
pub success_event: Option<String>,
#[serde(default)]
pub success_timeout_seconds: Option<u64>,
/// A keybind combo (e.g. "super + shift + s") to temporarily rebind so
/// its original action still runs but is chained with a
/// `--tour-event <success_event>` ping — for steps whose action has no
/// compositor-observable signal at all (e.g. taking a screenshot).
/// Reverted the moment this step is left, and self-healed on the next
/// launch if breadhelp crashes mid-step — see `ui::tour`.
#[serde(default)]
pub rebind_combo: Option<String>,
}
impl Step {
pub fn target(&self) -> Target {
if let Some(ns) = &self.target_namespace {
Target::Namespace(ns.clone())
} else if let Some(class) = &self.target_window_class {
Target::WindowClass(class.clone())
} else {
Target::None
}
}
pub fn success(&self) -> Success {
if let Some(id) = &self.success_event {
Success::Event(id.clone())
} else if let Some(secs) = self.success_timeout_seconds {
Success::Timeout(secs)
} else {
Success::Manual
}
}
}
#[derive(serde::Deserialize)]
struct TourFile {
#[serde(rename = "step", default)]
steps: Vec<Step>,
}
const SYSTEM_TOUR_PATH: &str = "/usr/share/breadhelp/content/tours/onboarding.toml";
pub fn load() -> Vec<Step> {
let Ok(text) = std::fs::read_to_string(Path::new(SYSTEM_TOUR_PATH)) else {
return Vec::new();
};
match toml::from_str::<TourFile>(&text) {
Ok(f) => f.steps,
Err(e) => {
eprintln!("breadhelp: {SYSTEM_TOUR_PATH} failed to parse: {e}");
Vec::new()
}
}
}

View file

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

View file

@ -1,42 +0,0 @@
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
});
// Best-effort revert of a tour step's temporarily-rebound keybind (see
// `ui::tour`'s crash-safety notes) on a clean exit — `rebind_temp` is a
// detached fire-and-forget `hyprctl` spawn, so it still completes even
// though this process is exiting right after.
app.connect_shutdown(|_| {
ui::tour::self_heal();
});
app.run();
}

View file

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

View file

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

View file

@ -1,96 +0,0 @@
use serde::Deserialize;
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}");
}
}
#[derive(Debug, Deserialize)]
pub struct Client {
pub class: String,
pub at: (i32, i32),
pub size: (i32, i32),
/// Numeric monitor id — join against `monitors()` by `Monitor::id` for a name.
pub monitor: i32,
}
#[derive(Debug, Deserialize)]
pub struct Monitor {
pub id: i32,
pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub focused: bool,
}
#[derive(Debug, Clone)]
pub struct LayerClient {
pub namespace: String,
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
pub monitor: String,
}
fn query_json(cmd: &str) -> Option<serde_json::Value> {
let output = Command::new("hyprctl").args(["-j", cmd]).output().ok()?;
output.status.success().then(|| serde_json::from_slice(&output.stdout).ok()).flatten()
}
pub fn clients() -> Vec<Client> {
query_json("clients").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
}
pub fn monitors() -> Vec<Monitor> {
query_json("monitors").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
}
/// `hyprctl -j layers` nests surfaces as `{monitor_name: {levels: {level: [surface]}}}`
/// with no fixed schema the `hyprland` crate or any sibling repo has a typed struct
/// for, so this walks the `serde_json::Value` by hand instead of deriving `Deserialize`.
pub fn layers() -> Vec<LayerClient> {
let Some(serde_json::Value::Object(by_monitor)) = query_json("layers") else {
return Vec::new();
};
let mut out = Vec::new();
for (monitor_name, monitor_val) in by_monitor {
let Some(levels) = monitor_val.get("levels").and_then(|v| v.as_object()) else {
continue;
};
for entries in levels.values().filter_map(|v| v.as_array()) {
for entry in entries {
let fields = (
entry.get("namespace").and_then(|v| v.as_str()),
entry.get("x").and_then(|v| v.as_i64()),
entry.get("y").and_then(|v| v.as_i64()),
entry.get("w").and_then(|v| v.as_i64()),
entry.get("h").and_then(|v| v.as_i64()),
);
if let (Some(namespace), Some(x), Some(y), Some(w), Some(h)) = fields {
out.push(LayerClient {
namespace: namespace.to_string(),
x: x as i32,
y: y as i32,
w: w as i32,
h: h as i32,
monitor: monitor_name.clone(),
});
}
}
}
}
out
}

View file

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

View file

@ -1,65 +0,0 @@
//! 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\
/* ui::tour — mask strips dim everything except the spotlight cutout; the \
callout is a small floating card near (or, for no-target steps, centered \
over) the highlighted thing. The shared stylesheet's `window { \
background-color: @bg }` rule matches every GtkWindow including these \
without this override GTK paints that opaque backdrop before the \
translucent mask box composites onto it, so the alpha never reaches the \
compositor and the \"dim\" reads as solid black. A class-qualified \
selector beats the shared rule's bare type selector on specificity \
regardless of stylesheet load order. */\n\
window.tour-window { background-color: transparent; }\n\
.tour-mask { background-color: rgba(0, 0, 0, 0.65); }\n\
.tour-callout { \
background-color: @surface; \
color: @on-surface; \
border-radius: 12px; \
padding: 16px 20px; \
}\n\
.tour-callout label.title { font-weight: bold; font-size: 15px; }\n\
/* The one payoff line in the whole tour — the user did the real thing and \
the compositor proved it shouldn't read as the same throwaway meta \
text as the step counter. */\n\
.tour-confirmed { color: @green; font-weight: bold; }\n\
/* Extra top margin (on top of the callout's uniform 12px row gap) marks \
the \"Show me\" fallback as its own beat, not a continuation of the \
instructional paragraph above it. */\n\
.tour-hint-row { margin-top: 8px; }\n\
";
thread_local! {
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn load(_display: &gtk4::gdk::Display) {
bread_theme::gtk::apply_shared();
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
}

View file

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

View file

@ -1,20 +0,0 @@
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()
}

View file

@ -1,180 +0,0 @@
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;
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(
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);
// The live guided tour (ui::tour) is the real hands-on onboarding now —
// a static text preview of the same guide titles Learn already lists
// was a fossil of the old in-window wizard and just duplicated Learn's
// sidebar. A single re-entry point is all this tab needs.
let replay_btn = Button::with_label("Replay the tour");
replay_btn.set_halign(Align::Start);
{
let parent = parent_window.clone();
replay_btn.connect_clicked(move |_| {
crate::ui::tour::restart(&WidgetExt::display(&parent));
});
}
vbox.append(&replay_btn);
vbox.append(&keybind_viewer::build(binds));
let root = ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast();
Home { root, banner_revealer, banner_label }
}
fn quick_fixes() -> Widget {
let heading_box = GBox::new(Orientation::Vertical, 8);
let heading = Label::new(Some("Quick fixes"));
heading.add_css_class("section-header");
heading.set_xalign(0.0);
heading_box.append(&heading);
let flow = FlowBox::new();
flow.set_selection_mode(gtk4::SelectionMode::None);
flow.set_max_children_per_line(4);
flow.set_column_spacing(8);
flow.set_row_spacing(8);
let status = Label::new(None);
status.set_xalign(0.0);
status.add_css_class("dim-label");
for (label, command) in [
("Fix sound", FIX_SOUND),
("Reconnect Wi-Fi", FIX_WIFI),
("Update everything", FIX_UPDATE),
("Create backup", FIX_BACKUP),
] {
let btn = Button::with_label(label);
let command = command.to_string();
let status = status.clone();
let label = label.to_string();
btn.connect_clicked(move |_| {
status.set_label(&format!("{label}\u{2026}"));
let status = status.clone();
let label = label.clone();
crate::services::exec::run_reporting(&command, move |success| {
status.set_label(&if success { format!("{label}: done.") } else { format!("{label}: something went wrong.") });
});
});
flow.insert(&btn, -1);
}
heading_box.append(&flow);
heading_box.append(&status);
heading_box.upcast()
}
fn mode_selector(initial: Mode, on_change: impl Fn(Mode) + 'static) -> DropDown {
let labels = StringList::new(&["Normal", "Beginner", "Dad", "Compact"]);
let dropdown = DropDown::new(Some(labels), gtk4::Expression::NONE);
dropdown.set_selected(mode_index(initial));
dropdown.set_tooltip_text(Some("Display mode"));
dropdown.update_property(&[gtk4::accessible::Property::Label("Display mode")]);
dropdown.connect_selected_notify(move |dd| {
let mode = match dd.selected() {
1 => Mode::Beginner,
2 => Mode::Dad,
3 => Mode::Compact,
_ => Mode::Normal,
};
on_change(mode);
});
dropdown
}
fn mode_index(mode: Mode) -> u32 {
match mode {
Mode::Normal => 0,
Mode::Beginner => 1,
Mode::Dad => 2,
Mode::Compact => 3,
}
}

View file

@ -1,115 +0,0 @@
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
}

View file

@ -1,100 +0,0 @@
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(),
}
}

View file

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

View file

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

View file

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

View file

@ -1,135 +0,0 @@
//! The floating explanation bubble: title/body (rendered through the same
//! constrained markdown subset guides use, so `[Show Keybind]`/`[Run]` tags
//! work here too), dot progress, Back/Next. Positioned just below (or, if
//! that would run off the bottom of the screen, above) the spotlighted
//! target; a `Target::None` step gets no anchors at all, which wlr-layer-
//! shell/gtk4-layer-shell centers on the monitor by convention.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation, Window};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use crate::content::keybinds::Keybind;
use crate::content::markdown;
use crate::content::tour::Step;
use super::target::Rect;
pub struct Callout {
pub window: Window,
pub back_btn: Button,
pub next_btn: Button,
pub skip_btn: Button,
pub demo_btn: Option<Button>,
}
const CALLOUT_WIDTH: i32 = 360;
/// Rough height estimate used only to decide "does this fit below the
/// target" before the widget has actually been laid out — doesn't need to
/// be exact, just enough to avoid running off the bottom of the screen.
const ESTIMATED_HEIGHT: i32 = 220;
#[allow(clippy::too_many_arguments)]
pub fn build(
monitor: &gdk4::Monitor,
rect: Option<Rect>,
monitor_size: (i32, i32),
step: &Step,
binds: &[Keybind],
step_index: usize,
total: usize,
is_last: bool,
confirmed: bool,
show_demo: bool,
) -> Callout {
let window = Window::new();
window.set_decorated(false);
window.add_css_class("tour-window");
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::OnDemand);
window.set_monitor(Some(monitor));
if let Some(rect) = rect {
let (mon_w, mon_h) = monitor_size;
window.set_anchor(Edge::Left, true);
window.set_anchor(Edge::Top, true);
let margin_left = (rect.x).clamp(20, (mon_w - CALLOUT_WIDTH - 20).max(20));
let below = rect.y + rect.h + 12;
let margin_top = if below + ESTIMATED_HEIGHT <= mon_h {
below
} else {
(rect.y - 12 - ESTIMATED_HEIGHT).max(20)
};
window.set_margin(Edge::Left, margin_left);
window.set_margin(Edge::Top, margin_top);
}
// No anchors for a `Target::None` step — left to the compositor, which
// centers unanchored layer surfaces.
let root = GBox::new(Orientation::Vertical, 12);
root.add_css_class("tour-callout");
root.set_size_request(CALLOUT_WIDTH, -1);
let title = Label::new(Some(&step.title));
title.add_css_class("title");
title.set_xalign(0.0);
title.set_wrap(true);
root.append(&title);
let body = markdown::render(&markdown::parse(&step.body), binds);
root.append(&body);
if confirmed {
let done = Label::new(Some("\u{2713} Nice work — click Next to continue."));
done.add_css_class("tour-confirmed");
done.set_xalign(0.0);
root.append(&done);
}
let demo_btn = if show_demo {
let hint_row = GBox::new(Orientation::Horizontal, 8);
hint_row.add_css_class("tour-hint-row");
let hint_label = Label::new(Some("Still there?"));
hint_label.add_css_class("dim-label");
hint_label.set_hexpand(true);
hint_label.set_xalign(0.0);
let btn = Button::with_label("Show me");
hint_row.append(&hint_label);
hint_row.append(&btn);
root.append(&hint_row);
Some(btn)
} else {
None
};
// Text only, not a dot row too — a separate row of dots next to this
// said the identical thing twice with no added legibility.
let counter = Label::new(Some(&format!("Step {} of {total}", step_index + 1)));
counter.add_css_class("dim-label");
counter.set_xalign(0.0);
root.append(&counter);
let nav = GBox::new(Orientation::Horizontal, 8);
let skip_btn = Button::with_label("Skip tour");
skip_btn.add_css_class("flat");
let back_btn = Button::with_label("Back");
back_btn.set_sensitive(step_index > 0);
let spacer = GBox::new(Orientation::Horizontal, 0);
spacer.set_hexpand(true);
let next_btn = Button::with_label(if is_last { "Finish" } else { "Next" });
next_btn.add_css_class("suggested-action");
nav.append(&skip_btn);
nav.append(&spacer);
nav.append(&back_btn);
nav.append(&next_btn);
root.append(&nav);
window.set_child(Some(&root));
window.present();
if confirmed {
next_btn.grab_focus();
}
Callout { window, back_btn, next_btn, skip_btn, demo_btn }
}

View file

@ -1,92 +0,0 @@
//! The spotlight: up to 4 `Layer::Overlay` strip windows (top/bottom/left/
//! right) sized to leave a target's bounding box as a real gap with no
//! surface over it at all — gtk4-layer-shell has no click-through/input-
//! region API (confirmed in its own docs), so the only way to let pointer
//! input reach the real UI underneath is to literally not cover it with
//! anything. A `Target::None` step instead gets one full-screen dim window,
//! since there's nothing to leave a gap around.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Window};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use super::target::Rect;
fn new_mask_window(monitor: &gdk4::Monitor) -> Window {
let window = Window::new();
window.set_decorated(false);
window.add_css_class("tour-window");
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::None);
window.set_monitor(Some(monitor));
let dim = GBox::new(gtk4::Orientation::Vertical, 0);
dim.add_css_class("tour-mask");
dim.set_hexpand(true);
dim.set_vexpand(true);
window.set_child(Some(&dim));
window
}
/// One dim window covering the whole monitor — used for `Target::None` steps.
pub fn full_screen(monitor: &gdk4::Monitor) -> Vec<Window> {
let window = new_mask_window(monitor);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.present();
vec![window]
}
/// The 4-strip letterbox around `rect`, in monitor-local coordinates.
/// `monitor_size` is `(width, height)` from `gdk4::Monitor::geometry()`.
pub fn around(monitor: &gdk4::Monitor, rect: Rect, monitor_size: (i32, i32)) -> Vec<Window> {
let (mon_w, mon_h) = monitor_size;
let mut windows = Vec::new();
let top_h = rect.y;
if top_h > 0 {
let w = new_mask_window(monitor);
w.set_anchor(Edge::Top, true);
w.set_anchor(Edge::Left, true);
w.set_anchor(Edge::Right, true);
w.set_default_size(-1, top_h);
w.present();
windows.push(w);
}
let bottom_h = mon_h - (rect.y + rect.h);
if bottom_h > 0 {
let w = new_mask_window(monitor);
w.set_anchor(Edge::Bottom, true);
w.set_anchor(Edge::Left, true);
w.set_anchor(Edge::Right, true);
w.set_default_size(-1, bottom_h);
w.present();
windows.push(w);
}
let left_w = rect.x;
if left_w > 0 {
let w = new_mask_window(monitor);
w.set_anchor(Edge::Left, true);
w.set_anchor(Edge::Top, true);
w.set_margin(Edge::Top, rect.y);
w.set_default_size(left_w, rect.h);
w.present();
windows.push(w);
}
let right_w = mon_w - (rect.x + rect.w);
if right_w > 0 {
let w = new_mask_window(monitor);
w.set_anchor(Edge::Right, true);
w.set_anchor(Edge::Top, true);
w.set_margin(Edge::Top, rect.y);
w.set_default_size(right_w, rect.h);
w.present();
windows.push(w);
}
windows
}

View file

@ -1,456 +0,0 @@
//! The live guided tour: a screen-wide overlay (layer-shell mask strips +
//! a floating callout, see `mask`/`callout`) that walks the user through
//! real on-screen components instead of describing them in a window of
//! breadhelp's own. Replaces the old in-window `Onboarding` wizard.
//!
//! Steps are user-paced, so the engine always tears down and rebuilds the 5
//! layer-shell windows on every transition rather than repositioning them in
//! place — simpler and cheap enough at this frequency.
mod callout;
mod mask;
mod target;
use std::cell::RefCell;
use std::time::Duration;
use gtk4::prelude::*;
use gtk4::Window;
use crate::config::State;
use crate::content::keybinds::{self, Keybind};
use crate::content::tour::{self, Step, Success};
use crate::services::{exec, hyprland};
use callout::Callout;
/// How long a step with no detected target waits before offering a "Show
/// me" fallback button. Deliberately NOT a timer that fires the demo
/// automatically — an auto-fired demo can't be told apart from the user's
/// own keypress from their side, silently teaching them nothing while
/// looking like it worked. The button preserves that the user chose it.
const HINT_DELAY_SECS: u32 = 10;
const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(400);
struct TourState {
steps: Vec<Step>,
binds: Vec<Keybind>,
display: gdk4::Display,
index: usize,
masks: Vec<Window>,
callout: Option<Callout>,
poll_source: Option<glib::SourceId>,
timeout_source: Option<glib::SourceId>,
hint_source: Option<glib::SourceId>,
/// Whether the current step's `success_event` has already fired. Doesn't
/// auto-advance — just switches the callout into a "done, click Next
/// when ready" state, so the user actually has time to read the step
/// instead of it vanishing the instant the target event arrives.
confirmed: bool,
/// Whether the "Show me" fallback button has appeared for this step yet
/// (see `HINT_DELAY_SECS`).
hint_visible: bool,
}
thread_local! {
static STATE: RefCell<Option<TourState>> = const { RefCell::new(None) };
}
/// Begin the tour from wherever `state.toml`'s `onboarding.step` left off —
/// the every-login autostart's genuine-first-run trigger.
pub fn start(display: &gdk4::Display) {
self_heal();
let steps = tour::load();
if steps.is_empty() {
return;
}
let start_index = (State::load().onboarding_step() as usize).min(steps.len() - 1);
STATE.with(|cell| {
*cell.borrow_mut() = Some(TourState {
steps,
binds: keybinds::load(),
display: display.clone(),
index: 0,
masks: Vec::new(),
callout: None,
poll_source: None,
timeout_source: None,
hint_source: None,
confirmed: false,
hint_visible: false,
});
});
enter_step(start_index);
}
/// `breadhelp --onboard` — restart from step 0 regardless of prior progress.
pub fn restart(display: &gdk4::Display) {
let mut state = State::load();
state.set_onboarding_completed(false);
state.set_onboarding_step(0);
start(display);
}
/// Reverts a keybind left rebound by a crashed previous run before it can
/// surprise the user (e.g. popping breadhelp open on a later, unrelated
/// press) — see `config::State::pending_rebind`. Safe to call any time,
/// including when nothing is pending.
pub fn self_heal() {
revert_pending_rebind();
}
/// A `--tour-event <id>` arrived, forwarded by `breadhelp-tour.lua` over the
/// same D-Bus argv-forwarding `--suggest` already uses. Hard no-op unless
/// the active tour's current step is actually waiting for this exact id —
/// a stray/late event must never be able to do anything visible. Does NOT
/// auto-advance to the next step — it confirms the current one (so the
/// callout can show a "done" state) and leaves moving on to an explicit
/// Next click, since jumping topics the instant the target event fires
/// gives the user no time to actually read anything about it.
pub fn on_tour_event(id: &str) {
let hit = STATE.with(|cell| {
let mut borrow = cell.borrow_mut();
let Some(state) = borrow.as_mut() else { return None };
// Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets
// set below, before `render_step` runs, so that render actually
// paints the "done" state instead of the stale pre-confirmation one
// — but that means a step whose render silently no-ops (e.g. the
// near-fullscreen suppression branch racing a `closelayer` event)
// would otherwise latch `confirmed = true` with no callout to show
// for it, permanently blocking every later retry. Checking
// `callout.is_some()` alongside `confirmed` is what actually
// prevents that: a confirmed-but-invisible step is not treated as
// handled, so a later duplicate event gets another chance.
if state.confirmed && state.callout.is_some() {
return None;
}
let matches = matches!(&state.steps[state.index].success(), Success::Event(e) if e == id);
if !matches {
return None;
}
state.confirmed = true;
Some((state.index, state.steps[state.index].clone(), state.binds.clone(), state.steps.len()))
});
if let Some((index, step, binds, total)) = hit {
// force_no_target=true: we already have authoritative confirmation
// from the real compositor event that fired this — see the comment
// in `render_step` on why re-querying live state here is unsafe.
render_step(index, &step, &binds, index + 1 >= total, true);
}
}
fn go_next() {
let next = STATE.with(|cell| cell.borrow().as_ref().map(|s| (s.index + 1, s.steps.len())));
let Some((next_index, total)) = next else { return };
if next_index >= total {
finish();
} else {
enter_step(next_index);
}
}
fn go_back() {
let index = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index));
let Some(index) = index else { return };
if index > 0 {
enter_step(index - 1);
}
}
fn finish() {
revert_pending_rebind();
teardown_visuals();
let mut state = State::load();
state.set_onboarding_completed(true);
STATE.with(|cell| *cell.borrow_mut() = None);
}
fn teardown_visuals() {
STATE.with(|cell| {
if let Some(state) = cell.borrow_mut().as_mut() {
if let Some(id) = state.poll_source.take() {
id.remove();
}
if let Some(id) = state.timeout_source.take() {
id.remove();
}
if let Some(id) = state.hint_source.take() {
id.remove();
}
}
});
clear_visuals();
}
/// Just the mask + callout windows — never touches `poll_source`/
/// `timeout_source`, so this is safe to call from inside their own
/// still-executing callbacks (unlike `teardown_visuals`).
fn clear_visuals() {
STATE.with(|cell| {
if let Some(state) = cell.borrow_mut().as_mut() {
for w in state.masks.drain(..) {
w.close();
}
if let Some(c) = state.callout.take() {
c.window.close();
}
}
});
}
fn enter_step(index: usize) {
revert_pending_rebind();
teardown_visuals();
let (step, binds, total) = STATE.with(|cell| {
let mut borrow = cell.borrow_mut();
let state = borrow.as_mut().expect("enter_step called with no active tour");
state.index = index;
state.confirmed = false;
state.hint_visible = false;
(state.steps[index].clone(), state.binds.clone(), state.steps.len())
});
let mut cfg = State::load();
cfg.set_onboarding_step(index as i64);
render_step(index, &step, &binds, index + 1 >= total, false);
// Watch for the target appearing from the user's OWN action (pressing
// the real shortcut) — never fire `launch` here. A step that teaches a
// shortcut only actually teaches it if the user is the one who presses
// it; see `HINT_DELAY_SECS` for the deliberate, user-initiated fallback.
if !target::is_resolved(&step.target()) {
schedule_poll(index);
if step.launch.is_some() {
schedule_hint(index);
}
}
if step.rebind_combo.is_some() {
apply_rebind_for_step(&step, &binds);
}
if let Success::Timeout(secs) = step.success() {
let source = glib::timeout_add_seconds_local(secs as u32, move || {
let still_current = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index)) == Some(index);
// Clear our own handle before advancing rather than letting
// `teardown_visuals` call `.remove()` on us — this closure is
// already mid-dispatch and about to return `Break`, and self-
// removing via `SourceId::remove()` from inside your own
// callback is a GLib footgun (can log a spurious critical).
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.timeout_source = None;
}
});
if still_current {
go_next();
}
glib::ControlFlow::Break
});
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.timeout_source = Some(source);
}
});
}
}
/// Watches for the step's target appearing — from the user's own real
/// keypress, or from clicking "Show me" (see `schedule_hint`), the poll
/// doesn't care which. Runs for as long as the step stays current; there's
/// no attempt cap because we're waiting on the user, not a fast-launching
/// process, and Next is always available as a manual escape hatch anyway.
fn schedule_poll(index: usize) {
let source = glib::timeout_add_local(WATCH_POLL_INTERVAL, move || {
let still_current = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index)) == Some(index);
if !still_current {
return glib::ControlFlow::Break;
}
let (step, binds, total) = STATE.with(|cell| {
let borrow = cell.borrow();
let s = borrow.as_ref().unwrap();
(s.steps[index].clone(), s.binds.clone(), s.steps.len())
});
if target::is_resolved(&step.target()) {
render_step(index, &step, &binds, index + 1 >= total, false);
clear_poll_source();
} else {
schedule_poll(index);
}
glib::ControlFlow::Break
});
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.poll_source = Some(source);
}
});
}
fn clear_poll_source() {
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.poll_source = None;
}
});
}
/// After `HINT_DELAY_SECS` with no target detected, reveals the "Show me"
/// fallback button by re-rendering the step with `hint_visible` set.
fn schedule_hint(index: usize) {
let source = glib::timeout_add_seconds_local(HINT_DELAY_SECS, move || {
// Clear our own handle first — see the self-removal note on the
// Timeout branch in `enter_step`, same footgun applies here.
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.hint_source = None;
}
});
let should_show = STATE.with(|cell| {
let borrow = cell.borrow();
borrow.as_ref().map(|s| s.index == index && !s.confirmed).unwrap_or(false)
});
if should_show {
let (step, binds, total) = STATE.with(|cell| {
let mut borrow = cell.borrow_mut();
let s = borrow.as_mut().unwrap();
s.hint_visible = true;
(s.steps[index].clone(), s.binds.clone(), s.steps.len())
});
render_step(index, &step, &binds, index + 1 >= total, false);
}
glib::ControlFlow::Break
});
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.hint_source = Some(source);
}
});
}
/// The "Show me" button's click handler — the only place `launch` is ever
/// fired, and only because the user explicitly asked for it.
fn trigger_demo() {
let launch = STATE.with(|cell| {
let borrow = cell.borrow();
borrow.as_ref().and_then(|s| s.steps[s.index].launch.clone())
});
if let Some(cmd) = launch {
exec::run(&cmd);
}
}
/// Marks the tour done without finishing every step — same end state as
/// completing it normally (won't auto-relaunch on next login), just
/// reachable at any point via the callout's low-emphasis "Skip tour".
fn skip_tour() {
finish();
}
/// Builds the mask + callout for `step` and installs them, tearing down
/// whatever was there before — called once immediately in `enter_step`
/// (usually with no target yet) and again by `schedule_poll` once a
/// `launch`ed app's window/layer actually appears.
fn render_step(index: usize, step: &Step, binds: &[Keybind], is_last: bool, force_no_target: bool) {
let (display, total, confirmed, hint_visible) = STATE.with(|cell| {
let borrow = cell.borrow();
let s = borrow.as_ref().unwrap();
(s.display.clone(), s.steps.len(), s.confirmed, s.hint_visible)
});
// `force_no_target` is set by `on_tour_event`'s confirmation path: a
// fresh `hyprctl -j layers` query right after a real `closelayer` event
// has been observed to still report the surface as present — Hyprland's
// event and its own layer-list snapshot aren't tightly ordered — which
// re-triggers the near-fullscreen suppression below and drops the
// confirmed callout right back into invisible limbo. `on_tour_event`
// already has authoritative confirmation the target is gone from the
// real compositor event itself, so that path skips re-querying live
// state entirely rather than trusting a `hyprctl` snapshot that may not
// have caught up yet.
let resolved = if force_no_target { None } else { target::resolve(&step.target(), &display) };
let monitor = resolved.as_ref().map(|r| r.monitor.clone()).or_else(|| target::focused_monitor(&display));
let Some(monitor) = monitor else { return };
let geo = monitor.geometry();
let monitor_size = (geo.width(), geo.height());
let rect = resolved.map(|r| r.rect);
// breadbox/breadclip/breadsearch anchor all 4 edges as fullscreen click-
// catchers — their visible launcher panel is small, but the surface
// hyprctl reports covers the whole monitor, and that surface owns all
// pointer input while it's open. A callout drawn now would be buried
// underneath it with no way to click Back/Next. The instructional
// callout already showed (with working buttons) before the app opened;
// once it's confirmed up, get out of the way entirely until it closes.
// A step whose target resolves this way should key `success_event` off
// the surface *closing* (e.g. `layer-closed:breadbox`), not opening —
// by the time that fires the surface is already gone, so `confirmed`'s
// re-render lands on a normal (non-fullscreen) target and is clickable.
if let Some(r) = rect {
if is_near_fullscreen(r, monitor_size) {
// Just the visuals, not `teardown_visuals()` — this can run from
// inside `schedule_poll`'s own still-executing closure, and that
// function also cancels `poll_source` via `SourceId::remove()`,
// which must never be called on a source's own in-progress
// dispatch (see the identical self-removal note in `enter_step`).
clear_visuals();
return;
}
}
let masks = match rect {
Some(r) => mask::around(&monitor, r, monitor_size),
None => mask::full_screen(&monitor),
};
// Only worth offering "Show me" while the target genuinely isn't up yet
// — once `rect` resolves, the user can plainly see it themselves.
let show_demo = hint_visible && !confirmed && rect.is_none() && step.launch.is_some();
let callout = callout::build(&monitor, rect, monitor_size, step, binds, index, total, is_last, confirmed, show_demo);
callout.back_btn.connect_clicked(|_| go_back());
callout.next_btn.connect_clicked(|_| go_next());
callout.skip_btn.connect_clicked(|_| skip_tour());
if let Some(btn) = &callout.demo_btn {
btn.connect_clicked(|_| trigger_demo());
}
clear_visuals();
STATE.with(|cell| {
if let Some(s) = cell.borrow_mut().as_mut() {
s.masks = masks;
s.callout = Some(callout);
}
});
}
/// breadbox/breadclip/breadsearch anchor all 4 edges as fullscreen click-
/// catchers, so their reported surface geometry is the whole monitor even
/// though the visible launcher panel is small.
fn is_near_fullscreen(rect: target::Rect, monitor_size: (i32, i32)) -> bool {
let (mon_w, mon_h) = monitor_size;
rect.w * 10 >= mon_w * 9 && rect.h * 10 >= mon_h * 9
}
fn apply_rebind_for_step(step: &Step, binds: &[Keybind]) {
let (Some(combo), Some(event_id)) = (&step.rebind_combo, &step.success_event) else { return };
let Some(kb) = keybinds::find(binds, combo) else { return };
let Some(raw) = &kb.raw else { return };
let Some(command) = raw.strip_prefix("exec,") else { return };
let mods_key = keybinds::to_hypr_mods_key(&kb.combo);
let original_bind_value = format!("{mods_key},{raw}");
let escaped = command.replace('\'', "'\\''");
let chained_bind_value = format!("{mods_key},exec,sh -c '{escaped} ; breadhelp --tour-event {event_id}'");
let mut cfg = State::load();
cfg.set_pending_rebind(&mods_key, &original_bind_value);
hyprland::rebind_temp(&chained_bind_value);
}
fn revert_pending_rebind() {
let mut cfg = State::load();
if let Some((_key, original)) = cfg.pending_rebind() {
hyprland::rebind_temp(&original);
}
cfg.clear_pending_rebind();
}

View file

@ -1,67 +0,0 @@
//! Resolves a `content::tour::Target` to a screen rect + the `gdk4::Monitor`
//! it's on, via `services::hyprland`'s `hyprctl -j` snapshots. `hyprctl -j
//! clients` reports a window's monitor as a numeric id, while GDK/Wayland
//! only exposes a monitor's connector name — `hyprctl -j monitors` is the
//! join between the two (`Client::monitor == Monitor::id`, then match
//! `Monitor::name` against `gdk4::Monitor::connector()`).
use gdk4::prelude::*;
use crate::content::tour::Target;
use crate::services::hyprland;
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
pub struct Resolved {
pub rect: Rect,
pub monitor: gdk4::Monitor,
}
/// `None` means the target isn't on screen (app not launched yet, or a
/// pure-concept `Target::None` step with no spotlight at all).
pub fn resolve(target: &Target, display: &gdk4::Display) -> Option<Resolved> {
let (rect, monitor_name) = match target {
Target::None => return None,
Target::Namespace(ns) => {
let layer = hyprland::layers().into_iter().find(|l| &l.namespace == ns)?;
(Rect { x: layer.x, y: layer.y, w: layer.w, h: layer.h }, layer.monitor)
}
Target::WindowClass(class) => {
let client = hyprland::clients().into_iter().find(|c| c.class.eq_ignore_ascii_case(class))?;
let name = hyprland::monitors().into_iter().find(|m| m.id == client.monitor)?.name;
(Rect { x: client.at.0, y: client.at.1, w: client.size.0, h: client.size.1 }, name)
}
};
let monitor = find_gdk_monitor(display, &monitor_name)?;
Some(Resolved { rect, monitor })
}
fn find_gdk_monitor(display: &gdk4::Display, name: &str) -> Option<gdk4::Monitor> {
display.monitors().iter::<gdk4::Monitor>().filter_map(|m| m.ok()).find(|m| m.connector().as_deref() == Some(name))
}
/// Falls back to this when a step has no target (nothing to resolve a
/// monitor from) or a `launch`ed target never appeared — the tour should
/// still show up on whichever output the user is actually looking at.
pub fn focused_monitor(display: &gdk4::Display) -> Option<gdk4::Monitor> {
let name = hyprland::monitors().into_iter().find(|m| m.focused)?.name;
find_gdk_monitor(display, &name)
}
/// Whether `target` is currently resolvable on screen — checked before
/// firing a step's `launch` command, since breadbox/breadclip/breadsearch
/// toggle-close on a second invocation of the same command; unconditionally
/// launching would close an already-open instance instead of opening one.
pub fn is_resolved(target: &Target) -> bool {
match target {
Target::None => true,
Target::Namespace(ns) => hyprland::layers().iter().any(|l| &l.namespace == ns),
Target::WindowClass(class) => hyprland::clients().iter().any(|c| c.class.eq_ignore_ascii_case(class)),
}
}

View file

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

View file

@ -1,135 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, StackTransitionType};
use crate::cli::Action;
use crate::config::State;
use crate::content::{keybinds, ContentStore};
use super::home::Home;
use super::{ask, learn, modes, tabs, tour};
const DEFAULT_TAB: &str = "home";
struct Handle {
window: ApplicationWindow,
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();
let display = WidgetExt::display(&handle.window);
if action.force_onboard {
tour::restart(&display);
return;
}
if let Some(id) = &action.tour_event {
tour::on_tour_event(id);
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 only starts the tour / pops the
// window open on a genuine first run — never on later logins.
if action.autostart && !State::load().onboarding_completed() {
tour::start(&display);
return;
}
let silent_autostart = action.autostart && State::load().onboarding_completed();
if !silent_autostart {
handle.window.present();
}
});
}
fn build(app: &Application) -> Handle {
// First thing on every cold start: revert any keybind a previous,
// crashed run may have left temporarily rebound mid tour-step (see
// `ui::tour`'s crash-safety notes) before the user could be surprised
// by it firing again.
tour::self_heal();
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(&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);
window.set_child(Some(&content_vbox));
// 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. On a genuine first run, the tour overlay runs
// independently of this window (see `tour::start`) — it never needs to
// be shown at all until the user explicitly opens it later.
Handle { window, home }
}

View file

@ -1,40 +0,0 @@
# 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' 'gtk4-layer-shell' '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"
}

View file

@ -1,9 +0,0 @@
[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