From 8a1f019b429da293ea6108cde8e96acba34628d9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:49:28 +0800 Subject: [PATCH] breadhelp: add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three views ("home", "learn", "ask") — one per tab in the Home/Learn/Ask Stack, switched via the same set_visible_child_name the tab switcher itself uses, then captured as a full known-size canvas (the window is a plain top-level, not layer-shell). Plumbed through breadhelp's own cli::Action/parse() (three more fields, same shape as force_onboard/autostart/suggest) rather than clap, matching the app's existing non-clap idiom. The HANDLES_COMMAND_LINE + thread_local HANDLE singleton architecture needed one addition beyond the usual NON_UNIQUE-flag fix: every invocation (including a --screenshot one) normally forwards to the already-built window over D-Bus and reuses it — worse than the plain GApplication case in the other apps, since here it's not just "message the existing instance" but literally switching tabs on and re-capturing the operator's real, live help-center window. NON_UNIQUE is now set whenever --screenshot is present in argv (checked before the Application is even built, since cli::parse() only runs per-invocation inside connect_command_line). --- Cargo.lock | 67 ++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 +++ src/cli.rs | 43 +++++++++++++++++++++++++++++- src/main.rs | 11 +++++++- src/screenshot.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/window.rs | 8 +++++- 6 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index 01eb182..42bf86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "async-channel" version = "2.5.0" @@ -26,6 +32,16 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-screenshots" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +dependencies = [ + "anyhow", + "bread-utils 0.3.1", + "tracing", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -48,13 +64,25 @@ dependencies = [ "toml_edit 0.22.27", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +dependencies = [ + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadhelp" version = "0.2.1" dependencies = [ + "anyhow", "async-channel", + "bread-screenshots", "bread-theme", - "bread-utils", + "bread-utils 0.3.0", "gdk4", "glib", "gtk4", @@ -614,6 +642,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "option-ext" version = "0.2.0" @@ -929,6 +963,37 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 2203c2b..865ff56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,7 @@ toml = "0.8" toml_edit = "0.22" async-channel = "2" bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. Not +# tag-pinned like the deps above since it doesn't have a tagged release yet. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } +anyhow = "1" diff --git a/src/cli.rs b/src/cli.rs index bb35833..88f1877 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,10 +25,39 @@ pub struct Action { /// 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, + /// Render the named tab, capture it, then exit instead of running + /// normally. Known views: "home", "learn", "ask". See `crate::screenshot`. + pub screenshot: Option, + /// PNG path to write the capture to. Required together with `screenshot`. + pub output: Option, + /// Capture canvas width — matches the isolated compositor's output + /// width (`bread-capture --isolate-width`). + pub width: u32, + /// Capture canvas height — see `width`. + pub height: u32, +} + +impl Action { + /// `None` for a normal run. Exits the process with an error if + /// `--screenshot` was given without `--output`, before any GTK setup + /// happens. + pub fn screenshot_request(&self) -> Option { + let view = self.screenshot.clone()?; + let Some(output) = self.output.clone() else { + eprintln!("breadhelp: --screenshot requires --output"); + std::process::exit(1); + }; + Some(crate::screenshot::ScreenshotRequest { + view, + output: output.into(), + width: self.width, + height: self.height, + }) + } } pub fn parse(args: &[std::ffi::OsString]) -> Action { - let mut action = Action::default(); + let mut action = Action { width: 1920, height: 1080, ..Action::default() }; let mut it = args.iter().skip(1); while let Some(arg) = it.next() { if arg == "--onboard" { @@ -39,6 +68,18 @@ pub fn parse(args: &[std::ffi::OsString]) -> Action { 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); + } else if arg == "--screenshot" { + action.screenshot = it.next().and_then(|s| s.to_str()).map(str::to_string); + } else if arg == "--output" { + action.output = it.next().and_then(|s| s.to_str()).map(str::to_string); + } else if arg == "--width" { + if let Some(v) = it.next().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) { + action.width = v; + } + } else if arg == "--height" { + if let Some(v) = it.next().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) { + action.height = v; + } } } action diff --git a/src/main.rs b/src/main.rs index 337863e..baefb55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod config; mod content; +mod screenshot; mod services; mod theme; mod ui; @@ -19,9 +20,17 @@ fn main() { // reach the running (primary) instance's argv, not just re-activate it // with no arguments — that's what lets a second launch re-trigger the // onboarding tour instead of only focusing the window. + let mut flags = ApplicationFlags::HANDLES_COMMAND_LINE; + if std::env::args().any(|a| a == "--screenshot") { + // Without this, a screenshot run would be forwarded over D-Bus to + // the real, already-running breadhelp instead of starting a fresh + // one — reusing (and mutating the tab of) the operator's actual + // help-center window instead of a disposable one. + flags |= ApplicationFlags::NON_UNIQUE; + } let app = gtk4::Application::builder() .application_id("com.breadway.breadhelp") - .flags(ApplicationFlags::HANDLES_COMMAND_LINE) + .flags(flags) .build(); app.connect_command_line(|app, cmdline| { diff --git a/src/screenshot.rs b/src/screenshot.rs new file mode 100644 index 0000000..5cfd9f7 --- /dev/null +++ b/src/screenshot.rs @@ -0,0 +1,67 @@ +//! `--screenshot` CLI mode: switch to the named tab, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! breadhelp has three tabs worth capturing (Home/Learn/Ask), switched via +//! the same `Stack::set_visible_child_name` the tab switcher itself uses — +//! see `ui::tabs`. The window is a plain top-level (not layer-shell), so a +//! full known-size canvas capture is enough, same reasoning as breadpad's +//! popup view. + +use gtk4::prelude::*; +use std::path::PathBuf; +use std::time::Duration; + +/// Extra settle time after `map` for the first frame to actually paint +/// before grim runs — `map` fires once the surface exists, not once +/// anything has been drawn into it. +const SETTLE_DELAY: Duration = Duration::from_millis(300); + +const KNOWN_VIEWS: &[&str] = &["home", "learn", "ask"]; + +#[derive(Clone)] +pub struct ScreenshotRequest { + pub view: String, + pub output: PathBuf, + pub width: u32, + pub height: u32, +} + +/// Wire up the given view's screenshot sequence against an already-built +/// window and its tab `Stack`. Every path here ends by exiting the process +/// — it never returns control to the normal help-center UI. +pub fn dispatch(window: >k4::ApplicationWindow, stack: >k4::Stack, req: ScreenshotRequest) { + if !KNOWN_VIEWS.contains(&req.view.as_str()) { + eprintln!( + "breadhelp: unknown screenshot view '{}' (known: {})", + req.view, + KNOWN_VIEWS.join(", ") + ); + std::process::exit(1); + } + stack.set_visible_child_name(&req.view); + + let output = req.output; + let (width, height) = (req.width as i32, req.height as i32); + window.connect_map(move |_| { + let output = output.clone(); + gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { + finish(bread_screenshots::capture_region(0, 0, width, height, &output)); + }); + }); + // The caller (`ui::window::present`) returns immediately after this for + // the screenshot path, skipping its own normal `window.present()` call + // — trigger it here instead, so `connect_map` above actually has + // something to fire for. + window.present(); +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadhelp: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +} diff --git a/src/ui/window.rs b/src/ui/window.rs index 85aa86e..dc6fb12 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -16,6 +16,7 @@ const DEFAULT_TAB: &str = "home"; struct Handle { window: ApplicationWindow, home: Home, + stack: Stack, } thread_local! { @@ -39,6 +40,11 @@ pub fn present(app: &Application, action: Action) { let handle = cell_ref.as_ref().unwrap(); let display = WidgetExt::display(&handle.window); + if let Some(req) = action.screenshot_request() { + crate::screenshot::dispatch(&handle.window, &handle.stack, req); + return; + } + if action.force_onboard { tour::restart(&display); return; @@ -131,5 +137,5 @@ fn build(app: &Application) -> Handle { // independently of this window (see `tour::start`) — it never needs to // be shown at all until the user explicitly opens it later. - Handle { window, home } + Handle { window, home, stack } }