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.
This commit is contained in:
Breadway 2026-07-15 18:48:10 +08:00
parent 78b2f420a5
commit b2f0b2a500
22 changed files with 1186 additions and 299 deletions

71
Cargo.lock generated
View file

@ -39,12 +39,14 @@ dependencies = [
[[package]] [[package]]
name = "breadhelp" name = "breadhelp"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"bread-theme", "bread-theme",
"gdk4",
"glib", "glib",
"gtk4", "gtk4",
"gtk4-layer-shell",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23", "toml 0.8.23",
@ -260,6 +262,7 @@ dependencies = [
"gdk-pixbuf", "gdk-pixbuf",
"gdk4-sys", "gdk4-sys",
"gio", "gio",
"gl",
"glib", "glib",
"libc", "libc",
"pango", "pango",
@ -323,6 +326,26 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "glib" name = "glib"
version = "0.22.7" version = "0.22.7"
@ -452,6 +475,34 @@ dependencies = [
"pango", "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]] [[package]]
name = "gtk4-macros" name = "gtk4-macros"
version = "0.11.0" version = "0.11.0"
@ -511,6 +562,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -526,6 +583,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.2" version = "2.8.2"
@ -1032,6 +1095,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

View file

@ -1,10 +1,12 @@
[package] [package]
name = "breadhelp" name = "breadhelp"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gdk4 = "0.11"
gtk4-layer-shell = "0.8"
glib = "0.22" glib = "0.22"
# Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/ # Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/
# breadbox/breadpad load, so this looks like part of the same desktop. # breadbox/breadpad load, so this looks like part of the same desktop.

View file

@ -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"

View file

@ -19,6 +19,12 @@ pub struct Action {
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a /// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
/// system event. Resolved to banner text by `services::breadd::resolve`. /// system event. Resolved to banner text by `services::breadd::resolve`.
pub suggest: Option<String>, 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 { pub fn parse(args: &[std::ffi::OsString]) -> Action {
@ -31,6 +37,8 @@ pub fn parse(args: &[std::ffi::OsString]) -> Action {
action.autostart = true; action.autostart = true;
} else if arg == "--suggest" { } else if arg == "--suggest" {
action.suggest = it.next().and_then(|s| s.to_str()).map(str::to_string); 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 action

View file

@ -119,6 +119,32 @@ impl State {
self.save(); 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 { 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"); 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) Mode::from_str(s)

View file

@ -135,6 +135,13 @@ fn render_spans(spans: &[Span], binds: &[Keybind]) -> gtk4::Widget {
let lbl = Label::new(None); let lbl = Label::new(None);
lbl.set_markup(&inline_markup(t)); lbl.set_markup(&inline_markup(t));
lbl.set_wrap(true); 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); lbl.set_xalign(0.0);
row.append(&lbl); row.append(&lbl);
} }

View file

@ -8,6 +8,7 @@
pub mod keybinds; pub mod keybinds;
pub mod markdown; pub mod markdown;
pub mod meta; pub mod meta;
pub mod tour;
pub mod troubleshoot; pub mod troubleshoot;
use std::collections::BTreeMap; use std::collections::BTreeMap;

View file

@ -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 <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

@ -30,5 +30,13 @@ fn main() {
glib::ExitCode::SUCCESS 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(); app.run();
} }

View file

@ -1,3 +1,4 @@
use serde::Deserialize;
use std::process::Command; use std::process::Command;
/// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately /// 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}"); 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

@ -27,6 +27,32 @@ label.keybind-chip { \
.dad-mode button { min-height: 44px; padding: 10px 18px; }\n\ .dad-mode button { min-height: 44px; padding: 10px 18px; }\n\
.beginner-mode button { min-height: 36px; }\n\ .beginner-mode button { min-height: 36px; }\n\
.compact-mode .view-content { padding: 12px; }\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! { thread_local! {

View file

@ -5,7 +5,7 @@ use gtk4::{
}; };
use crate::config::Mode; use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore}; use crate::content::keybinds::Keybind;
use super::{keybind_viewer, troubleshoot_wizard}; use super::{keybind_viewer, troubleshoot_wizard};
@ -37,7 +37,6 @@ impl Home {
} }
pub fn build( pub fn build(
store: &ContentStore,
binds: &[Keybind], binds: &[Keybind],
parent_window: &ApplicationWindow, parent_window: &ApplicationWindow,
initial_mode: Mode, initial_mode: Mode,
@ -88,19 +87,19 @@ pub fn build(
} }
vbox.append(&troubleshoot_btn); vbox.append(&troubleshoot_btn);
let getting_started = store.guides_in("getting-started"); // The live guided tour (ui::tour) is the real hands-on onboarding now —
if !getting_started.is_empty() { // a static text preview of the same guide titles Learn already lists
let heading = Label::new(Some("Getting Started")); // was a fossil of the old in-window wizard and just duplicated Learn's
heading.add_css_class("section-header"); // sidebar. A single re-entry point is all this tab needs.
heading.set_xalign(0.0); let replay_btn = Button::with_label("Replay the tour");
vbox.append(&heading); replay_btn.set_halign(Align::Start);
{
for guide in getting_started.iter().take(3) { let parent = parent_window.clone();
let lbl = Label::new(Some(&format!("\u{2022} {}", guide.meta.title))); replay_btn.connect_clicked(move |_| {
lbl.set_xalign(0.0); crate::ui::tour::restart(&WidgetExt::display(&parent));
vbox.append(&lbl); });
}
} }
vbox.append(&replay_btn);
vbox.append(&keybind_viewer::build(binds)); vbox.append(&keybind_viewer::build(binds));

View file

@ -4,7 +4,7 @@ pub mod home;
pub mod keybind_viewer; pub mod keybind_viewer;
pub mod learn; pub mod learn;
pub mod modes; pub mod modes;
pub mod onboarding;
pub mod tabs; pub mod tabs;
pub mod tour;
pub mod troubleshoot_wizard; pub mod troubleshoot_wizard;
pub mod window; pub mod window;

View file

@ -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<Label>,
current: Rc<Cell<usize>>,
}
impl Onboarding {
pub fn goto(&self, step: usize) {
let steps = steps::steps();
let step = step.min(steps.len() - 1);
self.current.set(step);
self.stack.set_visible_child_name(&step.to_string());
update_dots(&self.dots, step);
}
}
pub fn build(on_finish: impl Fn() + 'static) -> Onboarding {
let root = GBox::new(Orientation::Vertical, 16);
root.add_css_class("view-content");
// GTK4's built-in "background" class paints the theme's opaque default
// background — needed since this sits in an `Overlay` above the tabs.
root.add_css_class("background");
root.set_vexpand(true);
root.set_hexpand(true);
let all_steps = steps::steps();
let stack = Stack::new();
stack.set_transition_type(StackTransitionType::SlideLeftRight);
stack.set_vexpand(true);
stack.set_valign(Align::Center);
for (i, step) in all_steps.iter().enumerate() {
stack.add_named(&build_step_page(step), Some(&i.to_string()));
}
let dots_row = GBox::new(Orientation::Horizontal, 6);
dots_row.set_halign(Align::Center);
let dots: Vec<Label> = all_steps
.iter()
.map(|_| {
let dot = Label::new(Some("\u{25CF}"));
dot.add_css_class("dim-label");
dots_row.append(&dot);
dot
})
.collect();
let start_step = (State::load().onboarding_step() as usize).min(all_steps.len() - 1);
let current = Rc::new(Cell::new(start_step));
stack.set_visible_child_name(&start_step.to_string());
update_dots(&dots, start_step);
let nav = GBox::new(Orientation::Horizontal, 8);
nav.set_halign(Align::Fill);
let skip_btn = Button::with_label("Skip");
let back_btn = Button::with_label("Back");
back_btn.set_sensitive(start_step > 0);
let next_btn = Button::with_label(if start_step + 1 == all_steps.len() { "Finish" } else { "Next" });
next_btn.add_css_class("suggested-action");
let spacer = GBox::new(Orientation::Horizontal, 0);
spacer.set_hexpand(true);
nav.append(&skip_btn);
nav.append(&spacer);
nav.append(&back_btn);
nav.append(&next_btn);
let on_finish = Rc::new(on_finish);
back_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let next_btn = next_btn.clone();
let back_btn = back_btn.clone();
let total = all_steps.len();
move |_| {
let i = current.get();
if i == 0 {
return;
}
let new = i - 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(new > 0);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
next_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let back_btn = back_btn.clone();
let next_btn = next_btn.clone();
let total = all_steps.len();
let on_finish = on_finish.clone();
move |_| {
let i = current.get();
if i + 1 >= total {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
return;
}
let new = i + 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(true);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
skip_btn.connect_clicked({
let on_finish = on_finish.clone();
move |_| {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
}
});
root.append(&stack);
root.append(&dots_row);
root.append(&nav);
Onboarding { root, stack, dots, current }
}
fn build_step_page(step: &Step) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.set_valign(Align::Center);
vbox.set_halign(Align::Center);
vbox.set_margin_start(40);
vbox.set_margin_end(40);
let title = Label::new(Some(step.title));
title.add_css_class("title");
title.set_wrap(true);
title.set_justify(gtk4::Justification::Center);
vbox.append(&title);
let body = Label::new(Some(step.body));
body.set_wrap(true);
body.set_justify(gtk4::Justification::Center);
body.set_max_width_chars(56);
vbox.append(&body);
if let Some(cmd) = step.demo_cmd {
let try_btn = Button::with_label("Try it");
let cmd = cmd.to_string();
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
try_btn.set_halign(Align::Center);
vbox.append(&try_btn);
}
vbox.upcast()
}
fn update_dots(dots: &[Label], active: usize) {
for (i, dot) in dots.iter().enumerate() {
if i == active {
dot.remove_css_class("dim-label");
} else {
dot.add_css_class("dim-label");
}
}
}

View file

@ -1,51 +0,0 @@
pub struct Step {
pub title: &'static str,
pub body: &'static str,
/// What the step's "Try it" button runs, if any.
pub demo_cmd: Option<&'static str>,
}
pub fn steps() -> &'static [Step] {
&[
Step {
title: "Welcome to BOS",
body: "You're running a complete Hyprland desktop with the bread ecosystem preinstalled. This quick tour covers the essentials — skip any time, and reopen it later with `breadhelp --onboard`.",
demo_cmd: None,
},
Step {
title: "The SUPER key",
body: "SUPER (the Windows/Cmd key) is how almost everything starts in BOS. Hold it down, then press another key.",
demo_cmd: None,
},
Step {
title: "Essential keybinds",
body: "SUPER + Space opens the app launcher. SUPER + Return opens a terminal. SUPER + / always shows the full keybind cheatsheet, right here in this app.",
demo_cmd: Some("breadbox"),
},
Step {
title: "Opening apps",
body: "breadbox (launcher), breadpad (notes), breadman (tasks), and BOS Settings are all one SUPER shortcut away.",
demo_cmd: Some("bos-settings"),
},
Step {
title: "Make it yours",
body: "Pick a wallpaper and the whole desktop recolors itself — bar, launcher, notes, and this help center all pick up the same palette.",
demo_cmd: Some("breadpaper"),
},
Step {
title: "Internet & updates",
body: "bos-update refreshes both the system and the bread ecosystem in one go.",
demo_cmd: Some("bos-update"),
},
Step {
title: "Backups & safety",
body: "Every package change snapshots your system automatically. If an update breaks something, roll it back from BOS Settings or pick a snapshot right from the GRUB menu at boot.",
demo_cmd: None,
},
Step {
title: "You're ready!",
body: "That's everything to get started. Press SUPER + / any time you forget a shortcut.",
demo_cmd: None,
},
]
}

View file

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

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

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

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

@ -2,21 +2,19 @@ use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Overlay, Stack, StackTransitionType}; use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, StackTransitionType};
use crate::cli::Action; use crate::cli::Action;
use crate::config::State; use crate::config::State;
use crate::content::{keybinds, ContentStore}; use crate::content::{keybinds, ContentStore};
use super::home::Home; use super::home::Home;
use super::onboarding::{self, Onboarding}; use super::{ask, learn, modes, tabs, tour};
use super::{ask, learn, modes, tabs};
const DEFAULT_TAB: &str = "home"; const DEFAULT_TAB: &str = "home";
struct Handle { struct Handle {
window: ApplicationWindow, window: ApplicationWindow,
onboarding: Onboarding,
home: Home, home: Home,
} }
@ -39,14 +37,15 @@ pub fn present(app: &Application, action: Action) {
} }
let cell_ref = cell.borrow(); let cell_ref = cell.borrow();
let handle = cell_ref.as_ref().unwrap(); let handle = cell_ref.as_ref().unwrap();
let display = WidgetExt::display(&handle.window);
if action.force_onboard { if action.force_onboard {
let mut state = State::load(); tour::restart(&display);
state.set_onboarding_completed(false); return;
state.set_onboarding_step(0); }
handle.onboarding.goto(0);
handle.onboarding.root.set_visible(true); if let Some(id) = &action.tour_event {
handle.window.present(); tour::on_tour_event(id);
return; return;
} }
@ -64,8 +63,12 @@ pub fn present(app: &Application, action: Action) {
} }
// Every-login autostart builds the window (so the app is ready to // Every-login autostart builds the window (so the app is ready to
// respond to SUPER+/ instantly) but must only pop it open on a // respond to SUPER+/ instantly) but only starts the tour / pops the
// genuine first run — not on every subsequent login. // 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(); let silent_autostart = action.autostart && State::load().onboarding_completed();
if !silent_autostart { if !silent_autostart {
handle.window.present(); handle.window.present();
@ -74,6 +77,12 @@ pub fn present(app: &Application, action: Action) {
} }
fn build(app: &Application) -> Handle { 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() let window = ApplicationWindow::builder()
.application(app) .application(app)
.title("BOS Help") .title("BOS Help")
@ -94,7 +103,7 @@ fn build(app: &Application) -> Handle {
stack.set_vexpand(true); stack.set_vexpand(true);
stack.set_transition_type(StackTransitionType::SlideLeftRight); stack.set_transition_type(StackTransitionType::SlideLeftRight);
let home = super::home::build(&store, &binds, &window, state.mode(), { let home = super::home::build(&binds, &window, state.mode(), {
let window = window.clone(); let window = window.clone();
move |mode| { move |mode| {
modes::apply(&window, mode); modes::apply(&window, mode);
@ -113,30 +122,14 @@ fn build(app: &Application) -> Handle {
content_vbox.append(&switcher); content_vbox.append(&switcher);
content_vbox.append(&stack); content_vbox.append(&stack);
let overlay = Overlay::new(); window.set_child(Some(&content_vbox));
overlay.set_child(Some(&content_vbox));
// The on_finish closure needs to hide the onboarding widget once it's
// built, but the widget doesn't exist until `onboarding::build` returns —
// so it closes over a cell filled in right after.
let hide_target: Rc<RefCell<Option<GBox>>> = Rc::new(RefCell::new(None));
let hide_target_for_closure = hide_target.clone();
let ob = onboarding::build(move || {
if let Some(w) = hide_target_for_closure.borrow().as_ref() {
w.set_visible(false);
}
});
*hide_target.borrow_mut() = Some(ob.root.clone());
ob.root.set_visible(!state.onboarding_completed());
overlay.add_overlay(&ob.root);
window.set_child(Some(&overlay));
// Deliberately not presented here — `present()` (the caller) decides // Deliberately not presented here — `present()` (the caller) decides
// whether this initial build should actually be shown (see // whether this initial build should actually be shown (see
// `silent_autostart` above), so a from-cold every-login autostart with // `silent_autostart` above), so a from-cold every-login autostart with
// onboarding already complete builds a ready-but-hidden window instead // onboarding already complete builds a ready-but-hidden window instead
// of flashing it open. // 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, onboarding: ob, home } Handle { window, home }
} }

View file

@ -0,0 +1,39 @@
-- breadhelp-tour — forwards bread/Hyprland events the live guided tour
-- (breadhelp's ui::tour) uses to detect "the user actually did the thing"
-- and auto-advance a step. Always forwards; breadhelp's `--tour-event`
-- handler is a hard no-op unless a tour is currently waiting for that exact
-- id, so this module doesn't need to know whether a tour is even running.
--
-- `bread.window.opened`/`bread.workspace.changed` are already normalized by
-- breadd. Layer-surface opens/closes (breadbox, breadclip, breadsearch) are
-- NOT normalized — `openlayer`/`closelayer` fall through to the generic
-- `bread.hyprland.event` topic, which `on_raw` filters by raw kind.
--
-- Fullscreen click-catcher launchers (breadbox et al.) report their surface
-- as covering the whole monitor while open, so the tour can't safely show a
-- clickable callout at the same time — a step targeting one of these should
-- key its success on the *close* event (implying the user picked something
-- and it dismissed), not the open event, so the callout only ever reappears
-- once the launcher's surface is already gone and input contention is moot.
local M = bread.module({ name = "breadhelp-tour", version = "1.0.0" })
function M.on_load()
bread.on("bread.window.opened", function(event)
bread.exec("breadhelp --tour-event window:" .. event.data.class)
end)
bread.on("bread.workspace.changed", function(event)
bread.exec("breadhelp --tour-event workspace-changed")
end)
bread.hyprland.on_raw("openlayer", function(event)
bread.exec("breadhelp --tour-event layer:" .. event.data.data)
end)
bread.hyprland.on_raw("closelayer", function(event)
bread.exec("breadhelp --tour-event layer-closed:" .. event.data.data)
end)
end
return M

View file

@ -11,7 +11,7 @@ license=('MIT')
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO. # causing undefined-symbol errors. Disable LTO.
options=(!lto !debug) options=(!lto !debug)
depends=('gtk4' 'glib2' 'hicolor-icon-theme') depends=('gtk4' 'glib2' 'gtk4-layer-shell' 'hicolor-icon-theme')
optdepends=( optdepends=(
'snapper: create-backup one-click fix' 'snapper: create-backup one-click fix'
) )