From 29b244557394815fab0c8a221af5ecaddb7a27dc Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 15 Jul 2026 18:48:10 +0800 Subject: [PATCH] breadhelp 0.2.0: live guided tour overlay replacing static onboarding Replaces the old in-window onboarding wizard with a real screen-wide tour: dim + spotlight cutout around the actual on-screen component (breadbar, breadbox), floating callout teaching the shortcut, and event-driven confirmation via real Hyprland/breadd signals instead of click-through fakery. --- Cargo.toml | 4 +- content/tours/onboarding.toml | 24 ++ packaging/PKGBUILD | 2 +- src/cli.rs | 8 + src/config.rs | 26 ++ src/content/markdown.rs | 7 + src/content/mod.rs | 1 + src/content/tour.rs | 101 ++++++++ src/main.rs | 8 + src/services/hyprland.rs | 81 ++++++ src/theme.rs | 26 ++ src/ui/home.rs | 27 +- src/ui/mod.rs | 2 +- src/ui/onboarding/mod.rs | 196 --------------- src/ui/onboarding/steps.rs | 51 ---- src/ui/tour/callout.rs | 135 ++++++++++ src/ui/tour/mask.rs | 92 +++++++ src/ui/tour/mod.rs | 456 ++++++++++++++++++++++++++++++++++ src/ui/tour/target.rs | 67 +++++ src/ui/window.rs | 61 ++--- 20 files changed, 1077 insertions(+), 298 deletions(-) create mode 100644 content/tours/onboarding.toml create mode 100644 src/content/tour.rs delete mode 100644 src/ui/onboarding/mod.rs delete mode 100644 src/ui/onboarding/steps.rs create mode 100644 src/ui/tour/callout.rs create mode 100644 src/ui/tour/mask.rs create mode 100644 src/ui/tour/mod.rs create mode 100644 src/ui/tour/target.rs diff --git a/Cargo.toml b/Cargo.toml index f43db76..d63710a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "breadhelp" -version = "0.1.0" +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. diff --git a/content/tours/onboarding.toml b/content/tours/onboarding.toml new file mode 100644 index 0000000..950258e --- /dev/null +++ b/content/tours/onboarding.toml @@ -0,0 +1,24 @@ +# 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" diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index f454b9f..367b815 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -11,7 +11,7 @@ license=('MIT') # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. options=(!lto !debug) -depends=('gtk4' 'glib2' 'hicolor-icon-theme') +depends=('gtk4' 'glib2' 'gtk4-layer-shell' 'hicolor-icon-theme') optdepends=( 'snapper: create-backup one-click fix' ) diff --git a/src/cli.rs b/src/cli.rs index 70c6a04..bb35833 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,6 +19,12 @@ pub struct Action { /// 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, + /// 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, } pub fn parse(args: &[std::ffi::OsString]) -> Action { @@ -31,6 +37,8 @@ pub fn parse(args: &[std::ffi::OsString]) -> Action { 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 diff --git a/src/config.rs b/src/config.rs index ad745c6..acdca4a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -119,6 +119,32 @@ impl State { 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) diff --git a/src/content/markdown.rs b/src/content/markdown.rs index 332070b..c897c80 100644 --- a/src/content/markdown.rs +++ b/src/content/markdown.rs @@ -135,6 +135,13 @@ fn render_spans(spans: &[Span], binds: &[Keybind]) -> gtk4::Widget { 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); } diff --git a/src/content/mod.rs b/src/content/mod.rs index 7f279a5..814c6c9 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -8,6 +8,7 @@ pub mod keybinds; pub mod markdown; pub mod meta; +pub mod tour; pub mod troubleshoot; use std::collections::BTreeMap; diff --git a/src/content/tour.rs b/src/content/tour.rs new file mode 100644 index 0000000..d49709f --- /dev/null +++ b/src/content/tour.rs @@ -0,0 +1,101 @@ +//! 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 ` 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, + #[serde(default)] + pub target_window_class: Option, + /// 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, + #[serde(default)] + pub success_event: Option, + #[serde(default)] + pub success_timeout_seconds: Option, + /// A keybind combo (e.g. "super + shift + s") to temporarily rebind so + /// its original action still runs but is chained with a + /// `--tour-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, +} + +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, +} + +const SYSTEM_TOUR_PATH: &str = "/usr/share/breadhelp/content/tours/onboarding.toml"; + +pub fn load() -> Vec { + let Ok(text) = std::fs::read_to_string(Path::new(SYSTEM_TOUR_PATH)) else { + return Vec::new(); + }; + match toml::from_str::(&text) { + Ok(f) => f.steps, + Err(e) => { + eprintln!("breadhelp: {SYSTEM_TOUR_PATH} failed to parse: {e}"); + Vec::new() + } + } +} diff --git a/src/main.rs b/src/main.rs index f4ba916..337863e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,5 +30,13 @@ fn main() { 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(); } diff --git a/src/services/hyprland.rs b/src/services/hyprland.rs index 99b2e69..1060481 100644 --- a/src/services/hyprland.rs +++ b/src/services/hyprland.rs @@ -1,3 +1,4 @@ +use serde::Deserialize; use std::process::Command; /// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately @@ -13,3 +14,83 @@ pub fn rebind_temp(bind_value: &str) { 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 { + 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 { + query_json("clients").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() +} + +pub fn monitors() -> Vec { + 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 { + 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 +} diff --git a/src/theme.rs b/src/theme.rs index 5c9a3ad..0f87d6f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -27,6 +27,32 @@ label.keybind-chip { \ .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! { diff --git a/src/ui/home.rs b/src/ui/home.rs index 256fea6..f2c1152 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -5,7 +5,7 @@ use gtk4::{ }; use crate::config::Mode; -use crate::content::{keybinds::Keybind, ContentStore}; +use crate::content::keybinds::Keybind; use super::{keybind_viewer, troubleshoot_wizard}; @@ -37,7 +37,6 @@ impl Home { } pub fn build( - store: &ContentStore, binds: &[Keybind], parent_window: &ApplicationWindow, initial_mode: Mode, @@ -88,19 +87,19 @@ pub fn build( } vbox.append(&troubleshoot_btn); - let getting_started = store.guides_in("getting-started"); - if !getting_started.is_empty() { - let heading = Label::new(Some("Getting Started")); - heading.add_css_class("section-header"); - heading.set_xalign(0.0); - vbox.append(&heading); - - for guide in getting_started.iter().take(3) { - let lbl = Label::new(Some(&format!("\u{2022} {}", guide.meta.title))); - lbl.set_xalign(0.0); - vbox.append(&lbl); - } + // 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)); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 87c82db..8374aab 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,7 +4,7 @@ pub mod home; pub mod keybind_viewer; pub mod learn; pub mod modes; -pub mod onboarding; pub mod tabs; +pub mod tour; pub mod troubleshoot_wizard; pub mod window; diff --git a/src/ui/onboarding/mod.rs b/src/ui/onboarding/mod.rs deleted file mode 100644 index fca6c57..0000000 --- a/src/ui/onboarding/mod.rs +++ /dev/null @@ -1,196 +0,0 @@ -mod steps; - -use std::cell::Cell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{Align, Box as GBox, Button, Label, Orientation, Stack, StackTransitionType}; - -use crate::config::State; -use steps::Step; - -/// The onboarding tour: a `Stack` of step pages (plain `gtk4::Stack` with a -/// slide transition, not `Adw.Carousel` — no libadwaita dependency exists -/// anywhere in this ecosystem, and introducing one for a 7-step tour isn't -/// justified) with a Back/Skip/Next nav bar and a step-dots progress -/// indicator. The caller places `root` in an `Overlay` above the normal -/// tabbed content and toggles its visibility; `goto` lets a forced restart -/// (`breadhelp --onboard` while already running) jump back to step 0 without -/// rebuilding the widget tree. -pub struct Onboarding { - pub root: GBox, - stack: Stack, - dots: Vec