dev #2
22 changed files with 1186 additions and 299 deletions
71
Cargo.lock
generated
71
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
24
breadhelp/content/tours/onboarding.toml
Normal file
24
breadhelp/content/tours/onboarding.toml
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
101
breadhelp/src/content/tour.rs
Normal file
101
breadhelp/src/content/tour.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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! {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
breadhelp/src/ui/tour/callout.rs
Normal file
135
breadhelp/src/ui/tour/callout.rs
Normal 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
breadhelp/src/ui/tour/mask.rs
Normal file
92
breadhelp/src/ui/tour/mask.rs
Normal 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
breadhelp/src/ui/tour/mod.rs
Normal file
456
breadhelp/src/ui/tour/mod.rs
Normal 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
breadhelp/src/ui/tour/target.rs
Normal file
67
breadhelp/src/ui/tour/target.rs
Normal 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue