From 879ec1e7f4a4f251e6fcfd8c39daeace2d98fb08 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:43:55 +0800 Subject: [PATCH 1/2] breadpad: add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same reasoning as breadbar/breadbox/breadclip/breadsearch, but plumbed through breadpad's own hand-rolled arg parser (mod args) instead of clap — breadpad doesn't use clap at all, and adding a second, separate clap parser alongside the existing one would reject every one of breadpad's real flags (--type, --no-classify, --status, fire, calendar, ...) the moment it saw one clap didn't recognize. --screenshot/--output/--width/ --height are just three more fields on the same Args struct. Only the "popup" view (the compose window) is wired up — the reminder window needs a real stored Note to render, not worth fabricating one just for a screenshot pass. Also fixes the same single-instance footgun as the other apps: without NON_UNIQUE, a screenshot run would just toggle-close the operator's real already-open popup instead of getting its own window. --- Cargo.lock | 21 +++++++++ breadpad/Cargo.toml | 2 + breadpad/src/main.rs | 87 ++++++++++++++++++++++++++++++++++---- breadpad/src/screenshot.rs | 65 ++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 breadpad/src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index 756f0c3..cee96f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bread-screenshots" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -313,6 +323,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 5.0.1", + "serde", + "serde_json", +] + [[package]] name = "breadman" version = "0.3.4" @@ -335,6 +355,7 @@ name = "breadpad" version = "0.3.4" dependencies = [ "anyhow", + "bread-screenshots", "breadpad-shared", "chrono", "dirs 5.0.1", diff --git a/breadpad/Cargo.toml b/breadpad/Cargo.toml index c835bb9..4f53160 100644 --- a/breadpad/Cargo.toml +++ b/breadpad/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] breadpad-shared = { path = "../breadpad-shared" } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } anyhow.workspace = true ort.workspace = true tracing.workspace = true diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index aa924d0..4e83f1e 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -13,6 +13,8 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::{Arc, Once}; +mod screenshot; + static ORT_INIT: Once = Once::new(); fn init_ort_once(cfg: &Config) { @@ -41,6 +43,29 @@ mod args { pub model_info: bool, pub calendar_test: bool, pub calendar_list_uid: Option, + pub screenshot: Option, + pub output: Option, + pub width: u32, + pub height: u32, + } + + impl Args { + /// `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!("breadpad: --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 { @@ -53,6 +78,10 @@ mod args { model_info: false, calendar_test: false, calendar_list_uid: None, + screenshot: None, + output: None, + width: 1920, + height: 1080, }; let raw: Vec = std::env::args().skip(1).collect(); let mut i = 0; @@ -82,6 +111,26 @@ mod args { _ => {} } } + "--screenshot" => { + i += 1; + args.screenshot = raw.get(i).cloned(); + } + "--output" => { + i += 1; + args.output = raw.get(i).cloned(); + } + "--width" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.width = v; + } + } + "--height" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.height = v; + } + } _ => {} } i += 1; @@ -120,7 +169,8 @@ fn main() -> Result<()> { return cmd_calendar_list_uid(¬e_id, &cfg); } - run_popup(args.note_type, args.no_classify, cfg) + let screenshot_req = args.screenshot_request(); + run_popup(args.note_type, args.no_classify, cfg, screenshot_req) } fn cmd_status(cfg: &Config) -> Result<()> { @@ -510,22 +560,36 @@ fn build_reminder_window( window.present(); } -fn run_popup(preset_type: Option, no_classify: bool, cfg: Config) -> Result<()> { +fn run_popup( + preset_type: Option, + no_classify: bool, + cfg: Config, + screenshot_req: Option, +) -> Result<()> { // Try to get current Hyprland workspace let workspace = get_active_workspace(); - let app = gtk4::Application::builder() - .application_id("com.breadway.breadpad") - .build(); + let mut builder = gtk4::Application::builder().application_id("com.breadway.breadpad"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadpad instance, so without this a + // screenshot run would just toggle-close the *existing* instance's + // window instead of starting a fresh one that ever sees + // `screenshot_req` (see the `app.windows().first()` toggle below). + builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE); + } + let app = builder.build(); let cfg = Arc::new(cfg); app.connect_activate(move |app| { - if let Some(win) = app.windows().first().cloned() { - win.close(); - return; + if screenshot_req.is_none() { + if let Some(win) = app.windows().first().cloned() { + win.close(); + return; + } } - build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify); + build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify, screenshot_req.clone()); }); let code = app.run_with_args::(&[]); @@ -551,6 +615,7 @@ fn build_window( workspace: Option, preset_type: Option, no_classify: bool, + screenshot_req: Option, ) { let window = gtk4::ApplicationWindow::builder() .application(app) @@ -710,6 +775,10 @@ fn build_window( }); window.add_controller(key_ctrl); + if let Some(req) = screenshot_req { + screenshot::dispatch(&window, req); + } + window.present(); entry.grab_focus(); } diff --git a/breadpad/src/screenshot.rs b/breadpad/src/screenshot.rs new file mode 100644 index 0000000..e88679f --- /dev/null +++ b/breadpad/src/screenshot.rs @@ -0,0 +1,65 @@ +//! `--screenshot` CLI mode: render breadpad's compose popup, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! No clap here (unlike breadbar/breadbox/breadclip/breadsearch): breadpad +//! already has its own small hand-rolled flag parser (`mod args`) covering +//! `--type`/`--no-classify`/`--status`/`fire`/`calendar`/etc, and clap's +//! default "reject unknown flags" behavior would break every one of those +//! if bolted on as a second, separate parser. `--screenshot`/`--output`/ +//! `--width`/`--height` are just three more fields on that same `Args` +//! struct instead. +//! +//! Only the "popup" view (the compose window from `run_popup`) is wired up +//! — the reminder window (`run_reminder_window`, reached via `fire `) +//! needs a real stored `Note` to render, which isn't worth fabricating for +//! a screenshot pass. + +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(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, +/// not-yet-presented window. Every path here ends by exiting the process — +/// it never returns control to the normal popup UI. +pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) { + match req.view.as_str() { + "popup" => { + 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!("breadpad: unknown screenshot view '{other}' (known: popup)"); + std::process::exit(1); + } + } +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadpad: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +} From c13b7b0599cb42238bd21ae1c5f85967e1c5f640 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 16:37:01 +0800 Subject: [PATCH 2/2] breadman: add --screenshot CLI mode for automated capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same reasoning as breadpad, extending breadman's own hand-rolled arg parser rather than adding clap. --screenshot doubles as the view selector — breadman already supports opening directly to a named stack page via --view/initial_view, so this just feeds that same mechanism instead of needing a separate one. Full known-size canvas capture, plain top-level window (not layer-shell). Also fixes the same NON_UNIQUE footgun as breadpad/breadbox/etc. --- Cargo.lock | 1 + breadman/Cargo.toml | 2 + breadman/src/main.rs | 77 +++++++++++++++++++++++++++++++++++--- breadman/src/screenshot.rs | 58 ++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 breadman/src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index cee96f6..d34275d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,7 @@ name = "breadman" version = "0.3.4" dependencies = [ "anyhow", + "bread-screenshots", "breadpad-shared", "chrono", "dirs 5.0.1", diff --git a/breadman/Cargo.toml b/breadman/Cargo.toml index 577f684..3808281 100644 --- a/breadman/Cargo.toml +++ b/breadman/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] breadpad-shared = { path = "../breadpad-shared" } +# Capture primitives for `--screenshot` mode — see src/screenshot.rs. +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" } anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/breadman/src/main.rs b/breadman/src/main.rs index d46557a..e165a9e 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -13,6 +13,7 @@ use std::rc::Rc; use std::sync::Arc; mod editor; +mod screenshot; mod views; // ── Args ───────────────────────────────────────────────────────────────────── @@ -23,6 +24,29 @@ mod args { pub view: Option, pub done_id: Option, pub upcoming_plain: bool, + pub screenshot: Option, + pub output: Option, + pub width: u32, + pub height: u32, + } + + impl Args { + /// `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!("breadman: --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 { @@ -30,6 +54,10 @@ mod args { view: None, done_id: None, upcoming_plain: false, + screenshot: None, + output: None, + width: 1920, + height: 1080, }; let raw: Vec = std::env::args().skip(1).collect(); let mut i = 0; @@ -50,6 +78,26 @@ mod args { } args.view = Some("upcoming".into()); } + "--screenshot" => { + i += 1; + args.screenshot = raw.get(i).cloned(); + } + "--output" => { + i += 1; + args.output = raw.get(i).cloned(); + } + "--width" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.width = v; + } + } + "--height" => { + i += 1; + if let Some(v) = raw.get(i).and_then(|s| s.parse().ok()) { + args.height = v; + } + } _ => {} } i += 1; @@ -214,7 +262,9 @@ fn main() -> Result<()> { return cmd_upcoming_plain(); } - run_app(args.view, cfg) + let screenshot_req = args.screenshot_request(); + let view = screenshot_req.as_ref().map(|r| r.view.clone()).or(args.view); + run_app(view, cfg, screenshot_req) } fn cmd_done(id: &str) -> Result<()> { @@ -250,10 +300,20 @@ fn cmd_upcoming_plain() -> Result<()> { Ok(()) } -fn run_app(initial_view: Option, cfg: Config) -> Result<()> { - let app = gtk4::Application::builder() - .application_id("com.breadway.breadman") - .build(); +fn run_app( + initial_view: Option, + cfg: Config, + screenshot_req: Option, +) -> Result<()> { + let mut builder = gtk4::Application::builder().application_id("com.breadway.breadman"); + if screenshot_req.is_some() { + // GApplication is single-instance by default; this machine typically + // already has a real breadman instance, so without this a + // screenshot run would activate 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 cfg = Arc::new(cfg); let initial_view = Arc::new(initial_view); @@ -261,7 +321,7 @@ fn run_app(initial_view: Option, cfg: Config) -> Result<()> { app.connect_activate(move |app| { let cfg = cfg.as_ref().clone(); let initial_view = initial_view.as_deref().map(|s| s.to_string()); - if let Err(e) = build_app_window(app, cfg, initial_view) { + if let Err(e) = build_app_window(app, cfg, initial_view, screenshot_req.clone()) { tracing::error!("failed to build window: {}", e); } }); @@ -279,6 +339,7 @@ fn build_app_window( app: >k4::Application, cfg: Config, initial_view: Option, + screenshot_req: Option, ) -> Result<()> { apply_css(&cfg); @@ -465,6 +526,10 @@ fn build_app_window( } stack.set_visible_child_name(initial); + if let Some(req) = screenshot_req { + screenshot::dispatch(&window, req); + } + window.present(); Ok(()) } diff --git a/breadman/src/screenshot.rs b/breadman/src/screenshot.rs new file mode 100644 index 0000000..66da64a --- /dev/null +++ b/breadman/src/screenshot.rs @@ -0,0 +1,58 @@ +//! `--screenshot` CLI mode: render the named view, capture it via +//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s +//! `bread-capture` orchestrator, or run standalone for one-off captures. +//! +//! No clap here, same reasoning as breadpad: extends breadman's own +//! hand-rolled `mod args` instead of bolting on a second parser that would +//! reject its real flags (`--view`, `done`, `upcoming --plain`). +//! +//! `--screenshot ` doubles as the view selector — it's passed through +//! as `initial_view` (the same field `--view` already sets) rather than +//! needing a separate mechanism, since breadman already supports opening +//! directly to a named stack page. + +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(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, +/// not-yet-presented window. Every path here ends by exiting the process — +/// it never returns control to the normal note-manager UI. +/// +/// Unlike the other apps' `dispatch`, this doesn't validate `req.view` +/// against a known-views list — an invalid name just falls through to +/// breadman's own `unwrap_or("all")` default (see `build_app_window`), +/// same as `--view` already behaves for a normal run. +pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) { + 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)); + }); + }); +} + +fn finish(result: anyhow::Result<()>) { + match result { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("breadman: screenshot capture failed: {e}"); + std::process::exit(1); + } + } +}