diff --git a/Cargo.lock b/Cargo.lock index d8bf35b..3744843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,12 +39,14 @@ dependencies = [ [[package]] name = "breadhelp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-channel", "bread-theme", + "gdk4", "glib", "gtk4", + "gtk4-layer-shell", "serde", "serde_json", "toml 0.8.23", @@ -260,6 +262,7 @@ dependencies = [ "gdk-pixbuf", "gdk4-sys", "gio", + "gl", "glib", "libc", "pango", @@ -323,6 +326,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glib" version = "0.22.7" @@ -452,6 +475,34 @@ dependencies = [ "pango", ] +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + [[package]] name = "gtk4-macros" version = "0.11.0" @@ -511,6 +562,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "libc" version = "0.2.186" @@ -526,6 +583,12 @@ dependencies = [ "libc", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" @@ -1032,6 +1095,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "zmij" version = "1.0.21" diff --git a/breadhelp/Cargo.toml b/breadhelp/Cargo.toml index f43db76..d63710a 100644 --- a/breadhelp/Cargo.toml +++ b/breadhelp/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/breadhelp/content/tours/onboarding.toml b/breadhelp/content/tours/onboarding.toml new file mode 100644 index 0000000..950258e --- /dev/null +++ b/breadhelp/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/breadhelp/src/cli.rs b/breadhelp/src/cli.rs index 70c6a04..bb35833 100644 --- a/breadhelp/src/cli.rs +++ b/breadhelp/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/breadhelp/src/config.rs b/breadhelp/src/config.rs index ad745c6..acdca4a 100644 --- a/breadhelp/src/config.rs +++ b/breadhelp/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/breadhelp/src/content/markdown.rs b/breadhelp/src/content/markdown.rs index 332070b..c897c80 100644 --- a/breadhelp/src/content/markdown.rs +++ b/breadhelp/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/breadhelp/src/content/mod.rs b/breadhelp/src/content/mod.rs index 7f279a5..814c6c9 100644 --- a/breadhelp/src/content/mod.rs +++ b/breadhelp/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/breadhelp/src/content/tour.rs b/breadhelp/src/content/tour.rs new file mode 100644 index 0000000..d49709f --- /dev/null +++ b/breadhelp/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/breadhelp/src/main.rs b/breadhelp/src/main.rs index f4ba916..337863e 100644 --- a/breadhelp/src/main.rs +++ b/breadhelp/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/breadhelp/src/services/hyprland.rs b/breadhelp/src/services/hyprland.rs index 99b2e69..1060481 100644 --- a/breadhelp/src/services/hyprland.rs +++ b/breadhelp/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/breadhelp/src/theme.rs b/breadhelp/src/theme.rs index 5c9a3ad..0f87d6f 100644 --- a/breadhelp/src/theme.rs +++ b/breadhelp/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/breadhelp/src/ui/home.rs b/breadhelp/src/ui/home.rs index 256fea6..f2c1152 100644 --- a/breadhelp/src/ui/home.rs +++ b/breadhelp/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/breadhelp/src/ui/mod.rs b/breadhelp/src/ui/mod.rs index 87c82db..8374aab 100644 --- a/breadhelp/src/ui/mod.rs +++ b/breadhelp/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/breadhelp/src/ui/onboarding/mod.rs b/breadhelp/src/ui/onboarding/mod.rs deleted file mode 100644 index fca6c57..0000000 --- a/breadhelp/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