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 fea8f83204
commit 29b2445573
20 changed files with 1077 additions and 298 deletions

View file

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

View file

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

View file

@ -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);
}

View file

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

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

View file

@ -1,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<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\
.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! {

View file

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

View file

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

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,
},
]
}

135
src/ui/tour/callout.rs Normal file
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 }
}

92
src/ui/tour/mask.rs Normal file
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
}

456
src/ui/tour/mod.rs Normal file
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();
}

67
src/ui/tour/target.rs Normal file
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 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::config::State;
use crate::content::{keybinds, ContentStore};
use super::home::Home;
use super::onboarding::{self, Onboarding};
use super::{ask, learn, modes, tabs};
use super::{ask, learn, modes, tabs, tour};
const DEFAULT_TAB: &str = "home";
struct Handle {
window: ApplicationWindow,
onboarding: Onboarding,
home: Home,
}
@ -39,14 +37,15 @@ pub fn present(app: &Application, action: Action) {
}
let cell_ref = cell.borrow();
let handle = cell_ref.as_ref().unwrap();
let display = WidgetExt::display(&handle.window);
if action.force_onboard {
let mut state = State::load();
state.set_onboarding_completed(false);
state.set_onboarding_step(0);
handle.onboarding.goto(0);
handle.onboarding.root.set_visible(true);
handle.window.present();
tour::restart(&display);
return;
}
if let Some(id) = &action.tour_event {
tour::on_tour_event(id);
return;
}
@ -64,8 +63,12 @@ pub fn present(app: &Application, action: Action) {
}
// Every-login autostart builds the window (so the app is ready to
// respond to SUPER+/ instantly) but must only pop it open on a
// genuine first run — not on every subsequent login.
// respond to SUPER+/ instantly) but only starts the tour / pops the
// window open on a genuine first run — never on later logins.
if action.autostart && !State::load().onboarding_completed() {
tour::start(&display);
return;
}
let silent_autostart = action.autostart && State::load().onboarding_completed();
if !silent_autostart {
handle.window.present();
@ -74,6 +77,12 @@ pub fn present(app: &Application, action: Action) {
}
fn build(app: &Application) -> Handle {
// First thing on every cold start: revert any keybind a previous,
// crashed run may have left temporarily rebound mid tour-step (see
// `ui::tour`'s crash-safety notes) before the user could be surprised
// by it firing again.
tour::self_heal();
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Help")
@ -94,7 +103,7 @@ fn build(app: &Application) -> Handle {
stack.set_vexpand(true);
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();
move |mode| {
modes::apply(&window, mode);
@ -113,30 +122,14 @@ fn build(app: &Application) -> Handle {
content_vbox.append(&switcher);
content_vbox.append(&stack);
let overlay = Overlay::new();
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));
window.set_child(Some(&content_vbox));
// Deliberately not presented here — `present()` (the caller) decides
// whether this initial build should actually be shown (see
// `silent_autostart` above), so a from-cold every-login autostart with
// onboarding already complete builds a ready-but-hidden window instead
// of flashing it open.
// 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 }
}