breadsearch: add --screenshot CLI mode for automated capture

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.
This commit is contained in:
Breadway 2026-07-29 11:40:23 +08:00
parent 08717e51c0
commit 5098780f5b
4 changed files with 239 additions and 8 deletions

107
Cargo.lock generated
View file

@ -51,12 +51,56 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.14" version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.103" 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" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils 0.3.0",
"hex", "hex",
"ort", "ort",
"sha2 0.10.9", "sha2 0.10.9",
@ -150,6 +194,16 @@ dependencies = [
"ureq 2.12.1", "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]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.2.3" version = "0.2.3"
@ -171,6 +225,16 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "breadmill" name = "breadmill"
version = "0.3.0" version = "0.3.0"
@ -198,8 +262,11 @@ dependencies = [
name = "breadsearch" name = "breadsearch"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow",
"bread-screenshots",
"bread-theme", "bread-theme",
"breadsearch-shared", "breadsearch-shared",
"clap",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"serde_json", "serde_json",
@ -364,6 +431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive",
] ]
[[package]] [[package]]
@ -372,11 +440,24 @@ version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [ dependencies = [
"anstream",
"anstyle", "anstyle",
"clap_lex", "clap_lex",
"strsim", "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]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "1.1.0" version = "1.1.0"
@ -394,6 +475,12 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.9.1" version = "0.9.1"
@ -1612,6 +1699,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@ -2034,6 +2127,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "onig" name = "onig"
version = "6.5.3" version = "6.5.3"
@ -3313,6 +3412,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"

View file

@ -11,6 +11,10 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadsearch-shared = { path = "../breadsearch-shared" } breadsearch-shared = { path = "../breadsearch-shared" }
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } 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 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"
serde_json = "1" serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"

View file

@ -18,6 +18,8 @@ use gtk4::{
}; };
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
mod screenshot;
// ---- Theming ---------------------------------------------------------------- // ---- Theming ----------------------------------------------------------------
fn build_css(p: &Palette) -> String { fn build_css(p: &Palette) -> String {
@ -208,10 +210,17 @@ fn open_folder(path: &str) {
// ---- UI --------------------------------------------------------------------- // ---- UI ---------------------------------------------------------------------
fn run_ui() { fn run_ui(screenshot_req: Option<screenshot::ScreenshotRequest>) {
let app = Application::builder() let mut builder = Application::builder().application_id("com.breadway.breadsearch");
.application_id("com.breadway.breadsearch") if screenshot_req.is_some() {
.build(); // 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<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None)); let debounce_id: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
@ -419,18 +428,37 @@ fn run_ui() {
window.add_controller(outside_click); window.add_controller(outside_click);
window.connect_destroy(|_| cleanup_pid()); window.connect_destroy(|_| cleanup_pid());
if let Some(req) = screenshot_req.clone() {
screenshot::dispatch(&window, req);
}
window.present(); window.present();
search.grab_focus(); 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 ------------------------------------------------------------------- // ---- Main -------------------------------------------------------------------
fn 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; return;
} }
run_ui(); run_ui(screenshot_req);
} }

View file

@ -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<String>,
/// PNG path to write the capture to. Required together with --screenshot.
#[arg(long)]
pub output: Option<PathBuf>,
/// 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<ScreenshotRequest> {
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: &gtk4::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);
}
}
}