From b125facc6791af3e9689bf64154527c16e0a5acb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:37:01 +0800 Subject: [PATCH] breadclip: 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 breadbar/breadbox: render the clipboard-history panel, capture it via bread-screenshots, then exit. One view ("history"), full known-size canvas capture since the panel isn't its own layer surface. active_window()/focused_monitor() (used to anchor the panel next to the real focused window) both resolve to None inside bread-capture's isolated environment (HYPRLAND_INSTANCE_SIGNATURE is deliberately unset there), so the panel falls back to its already-existing centered layout — exactly the deterministic behavior a screenshot needs, with no screenshot-mode- specific positioning logic required. Also fixes the same singleton footgun as breadbox: toggle_or_kill() kills whatever's holding breadclip's single-instance lock, which is typically the real running instance. A screenshot run now skips it entirely instead of fighting over (and killing) the operator's real clipboard panel. --- Cargo.lock | 205 ++++++++++++++++++++++++++++++++++-- breadclip/Cargo.toml | 5 + breadclip/src/main.rs | 57 +++++++--- breadclip/src/screenshot.rs | 100 ++++++++++++++++++ 4 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 breadclip/src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index e1ab23b..925c25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,62 @@ dependencies = [ "zerocopy", ] +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "autocfg" version = "1.5.1" @@ -35,6 +91,16 @@ dependencies = [ "generic-array", ] +[[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 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev)", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -80,13 +146,26 @@ 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 = "breadclip" version = "0.2.1" dependencies = [ + "anyhow", + "bread-screenshots", "bread-theme", "bread-utils 0.3.0", "breadclip-core", + "clap", "gtk4", "gtk4-layer-shell", "serde_json", @@ -108,7 +187,7 @@ dependencies = [ name = "breadclipd" version = "0.2.1" dependencies = [ - "bread-utils 0.3.1", + "bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?tag=v0.3.1)", "breadclip-core", "serde_json", "tempfile", @@ -163,6 +242,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -303,7 +428,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -495,7 +620,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -629,7 +754,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -697,6 +822,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -768,6 +899,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 = "option-ext" version = "0.2.0" @@ -922,7 +1059,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -985,6 +1122,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -996,6 +1139,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -1045,7 +1199,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1140,6 +1294,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 2.0.119", +] + +[[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 = "typenum" version = "1.20.1" @@ -1152,6 +1337,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1298,7 +1489,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/breadclip/Cargo.toml b/breadclip/Cargo.toml index 032d7d0..258e60a 100644 --- a/breadclip/Cargo.toml +++ b/breadclip/Cargo.toml @@ -12,6 +12,11 @@ breadclip-core = { path = "../breadclip-core" } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } # (Path corrected: previously pointed at a since-cleaned-up "bread-ecosystem-fix-worktree" checkout that no longer exists on disk.) bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["gtk"] } +# 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" } 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/breadclip/src/main.rs b/breadclip/src/main.rs index 282b4a6..05b0dc8 100644 --- a/breadclip/src/main.rs +++ b/breadclip/src/main.rs @@ -1,4 +1,5 @@ mod css; +mod screenshot; use breadclip_core::{ClipEntry, HistoryDb}; use bread_theme::{load_palette}; @@ -184,10 +185,17 @@ fn do_copy(entry: &ClipEntry) { // ---- UI --------------------------------------------------------------------- -fn run_ui(entries: Vec) { - let app = Application::builder() - .application_id("com.breadway.breadclip") - .build(); +fn run_ui(entries: Vec, screenshot_req: Option) { + let mut builder = Application::builder().application_id("com.breadway.breadclip"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadclip 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(); app.connect_activate(move |app| { bread_theme::gtk::apply_shared(); @@ -408,25 +416,50 @@ fn run_ui(entries: Vec) { bread_utils::gtk_popup::close_on_outside_click(&window, &panel, move || close_outside()); } + 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() { + use clap::Parser; + let cli = screenshot::Cli::parse(); + let screenshot_req = cli.screenshot_request(); + + // `toggle_or_kill` kills whatever's holding the single-instance lock — + // a real, already-running breadclip included. A screenshot run must + // never touch it: it's a separate, disposable instance by design (same + // reasoning as breadbar's `allow_multiple_instances`), not a toggle of + // the operator's real clipboard panel. + // // Kept alive for the rest of `main` — dropping it releases the // single-instance lock and removes the pid file, which happens // naturally once `run_ui` returns (after the window closes). - let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadclip") { - Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), - Ok(bread_utils::singleton::Toggle::KilledExisting) => return, - Err(e) => { - eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); - None + let _singleton_guard = if screenshot_req.is_some() { + None + } else { + match bread_utils::singleton::toggle_or_kill("breadclip") { + Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard), + Ok(bread_utils::singleton::Toggle::KilledExisting) => return, + Err(e) => { + eprintln!("breadclip: single-instance lock unavailable ({e}); continuing without it"); + None + } } }; @@ -434,5 +467,5 @@ fn main() { .and_then(|db| db.list_entries(MAX_ENTRIES)) .unwrap_or_default(); - run_ui(entries); + run_ui(entries, screenshot_req); } diff --git a/breadclip/src/screenshot.rs b/breadclip/src/screenshot.rs new file mode 100644 index 0000000..b4fd8fa --- /dev/null +++ b/breadclip/src/screenshot.rs @@ -0,0 +1,100 @@ +//! `--screenshot` CLI mode: render breadclip's clipboard-history panel, +//! capture it via `bread-screenshots`, then exit — driven by +//! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for +//! one-off captures. +//! +//! breadclip has one view worth capturing: the history panel itself. Like +//! breadbox, it's a `halign`/`valign`-positioned panel over a full-screen +//! transparent overlay rather than its own layer surface, so a full +//! known-size canvas capture is the simplest reliable option. In screenshot +//! mode there's never a focused window to anchor the panel next to (see +//! `bread_utils::hypr::active_window` in `main.rs`, which the isolated +//! capture environment always reports `None` for — deliberately, see +//! `bread-capture`'s isolation module), so the panel always falls back to +//! centered, which is exactly what we want for a consistent screenshot. + +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 = "breadclip")] +pub struct Cli { + /// Render the named view, capture it, then exit instead of running + /// normally. Known views: "history". + #[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!("breadclip: --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 history-panel UI. +pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) { + match req.view.as_str() { + "history" => { + 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!("breadclip: unknown screenshot view '{other}' (known: history)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadclip: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +}