From 5098780f5b7501437ad179905e5700868d3cb72e Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:40:23 +0800 Subject: [PATCH] breadsearch: add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as breadbox/breadclip: one view ("search"), full known-size canvas capture since the search panel isn't its own layer surface. Also fixes the same PID-file-toggle footgun — a screenshot run now skips toggle_or_continue() entirely instead of killing whatever real instance holds breadsearch.pid. --- Cargo.lock | 107 +++++++++++++++++++++++++++++++++- breadsearch/Cargo.toml | 4 ++ breadsearch/src/main.rs | 42 ++++++++++--- breadsearch/src/screenshot.rs | 94 +++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 breadsearch/src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index 49e20a5..ad06819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,12 +51,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -141,7 +185,7 @@ version = "0.3.0" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" dependencies = [ "anyhow", - "bread-utils", + "bread-utils 0.3.0", "hex", "ort", "sha2 0.10.9", @@ -150,6 +194,16 @@ dependencies = [ "ureq 2.12.1", ] +[[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" @@ -171,6 +225,16 @@ dependencies = [ "serde_json", ] +[[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 = "breadmill" version = "0.3.0" @@ -198,8 +262,11 @@ dependencies = [ name = "breadsearch" version = "0.3.0" dependencies = [ + "anyhow", + "bread-screenshots", "bread-theme", "breadsearch-shared", + "clap", "gtk4", "gtk4-layer-shell", "serde_json", @@ -364,6 +431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -372,11 +440,24 @@ version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ + "anstream", "anstyle", "clap_lex", "strsim", ] +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "1.1.0" @@ -394,6 +475,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.9.1" @@ -1612,6 +1699,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -2034,6 +2127,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "onig" version = "6.5.3" @@ -3313,6 +3412,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" diff --git a/breadsearch/Cargo.toml b/breadsearch/Cargo.toml index 241457f..c661de3 100644 --- a/breadsearch/Cargo.toml +++ b/breadsearch/Cargo.toml @@ -11,6 +11,10 @@ path = "src/main.rs" [dependencies] breadsearch-shared = { path = "../breadsearch-shared" } bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" serde_json = "1" +clap = { version = "4", features = ["derive"] } +anyhow = "1" diff --git a/breadsearch/src/main.rs b/breadsearch/src/main.rs index a9812fe..146dd46 100644 --- a/breadsearch/src/main.rs +++ b/breadsearch/src/main.rs @@ -18,6 +18,8 @@ use gtk4::{ }; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +mod screenshot; + // ---- Theming ---------------------------------------------------------------- fn build_css(p: &Palette) -> String { @@ -208,10 +210,17 @@ fn open_folder(path: &str) { // ---- UI --------------------------------------------------------------------- -fn run_ui() { - let app = Application::builder() - .application_id("com.breadway.breadsearch") - .build(); +fn run_ui(screenshot_req: Option) { + let mut builder = Application::builder().application_id("com.breadway.breadsearch"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadsearch instance, so without this a + // screenshot run would just message the *existing* instance instead + // of starting a fresh one that ever sees `screenshot_req`. + builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); + let is_screenshot_run = screenshot_req.is_some(); let debounce_id: Rc>> = Rc::new(RefCell::new(None)); @@ -419,18 +428,37 @@ fn run_ui() { window.add_controller(outside_click); window.connect_destroy(|_| cleanup_pid()); + + if let Some(req) = screenshot_req.clone() { + screenshot::dispatch(&window, req); + } + window.present(); search.grab_focus(); }); - app.run(); + if is_screenshot_run { + // GLib's own option parser otherwise rejects --screenshot/--output + // before clap ever sees them (`Cli::parse()` already ran in `main`, + // over the real argv). + app.run_with_args(&[] as &[&str]); + } else { + app.run(); + } } // ---- Main ------------------------------------------------------------------- fn main() { - if !toggle_or_continue() { + use clap::Parser; + let cli = screenshot::Cli::parse(); + let screenshot_req = cli.screenshot_request(); + + // The PID-file toggle kills whatever's holding the file — a real, + // already-running breadsearch instance included. A screenshot run must + // never touch it: it's a separate, disposable instance by design. + if screenshot_req.is_none() && !toggle_or_continue() { return; } - run_ui(); + run_ui(screenshot_req); } diff --git a/breadsearch/src/screenshot.rs b/breadsearch/src/screenshot.rs new file mode 100644 index 0000000..4373fb3 --- /dev/null +++ b/breadsearch/src/screenshot.rs @@ -0,0 +1,94 @@ +//! `--screenshot` CLI mode: render breadsearch's search panel, capture it +//! via `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! Same reasoning as breadbox: one view ("search"), full known-size canvas +//! capture since the panel isn't its own layer surface. Captures whatever +//! the panel shows at settle time — normally the "Type to search…" empty +//! state, since there's no query to type in an automated run. + +use clap::Parser; +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); + +#[derive(Parser)] +#[command(name = "breadsearch")] +pub struct Cli { + /// Render the named view, capture it, then exit instead of running + /// normally. Known views: "search". + #[arg(long)] + pub screenshot: Option, + + /// PNG path to write the capture to. Required together with --screenshot. + #[arg(long)] + pub output: Option, + + /// Capture canvas width — matches the isolated compositor's output width + /// (`bread-capture --isolate-width`). + #[arg(long, default_value_t = 1920)] + pub width: u32, + + /// Capture canvas height — see `width`. + #[arg(long, default_value_t = 1080)] + pub height: u32, +} + +#[derive(Clone)] +pub struct ScreenshotRequest { + pub view: String, + pub output: PathBuf, + pub width: u32, + pub height: u32, +} + +impl Cli { + /// `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!("breadsearch: --screenshot requires --output"); + std::process::exit(1); + }; + Some(ScreenshotRequest { view, output, width: self.width, height: self.height }) + } +} + +/// Wire up the given view's screenshot sequence against an already-built, +/// not-yet-presented window. Every path here ends by exiting the process — +/// it never returns control to the normal search UI. +pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) { + match req.view.as_str() { + "search" => { + 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)); + }); + }); + } + other => { + eprintln!("breadsearch: unknown screenshot view '{other}' (known: search)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadsearch: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +}