breadman: add --screenshot CLI mode for automated capture

Same reasoning as breadpad, extending breadman's own hand-rolled arg
parser rather than adding clap. --screenshot <view> 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.
This commit is contained in:
Breadway 2026-07-29 16:37:01 +08:00
parent 879ec1e7f4
commit c13b7b0599
4 changed files with 132 additions and 6 deletions

1
Cargo.lock generated
View file

@ -338,6 +338,7 @@ name = "breadman"
version = "0.3.4" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-screenshots",
"breadpad-shared", "breadpad-shared",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",

View file

@ -11,6 +11,8 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadpad-shared = { path = "../breadpad-shared" } 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 anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -13,6 +13,7 @@ use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
mod editor; mod editor;
mod screenshot;
mod views; mod views;
// ── Args ───────────────────────────────────────────────────────────────────── // ── Args ─────────────────────────────────────────────────────────────────────
@ -23,6 +24,29 @@ mod args {
pub view: Option<String>, pub view: Option<String>,
pub done_id: Option<String>, pub done_id: Option<String>,
pub upcoming_plain: bool, pub upcoming_plain: bool,
pub screenshot: Option<String>,
pub output: Option<String>,
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<crate::screenshot::ScreenshotRequest> {
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 { pub fn parse() -> Args {
@ -30,6 +54,10 @@ mod args {
view: None, view: None,
done_id: None, done_id: None,
upcoming_plain: false, upcoming_plain: false,
screenshot: None,
output: None,
width: 1920,
height: 1080,
}; };
let raw: Vec<String> = std::env::args().skip(1).collect(); let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0; let mut i = 0;
@ -50,6 +78,26 @@ mod args {
} }
args.view = Some("upcoming".into()); 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; i += 1;
@ -214,7 +262,9 @@ fn main() -> Result<()> {
return cmd_upcoming_plain(); 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<()> { fn cmd_done(id: &str) -> Result<()> {
@ -250,10 +300,20 @@ fn cmd_upcoming_plain() -> Result<()> {
Ok(()) Ok(())
} }
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> { fn run_app(
let app = gtk4::Application::builder() initial_view: Option<String>,
.application_id("com.breadway.breadman") cfg: Config,
.build(); screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> 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 cfg = Arc::new(cfg);
let initial_view = Arc::new(initial_view); let initial_view = Arc::new(initial_view);
@ -261,7 +321,7 @@ fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
app.connect_activate(move |app| { app.connect_activate(move |app| {
let cfg = cfg.as_ref().clone(); let cfg = cfg.as_ref().clone();
let initial_view = initial_view.as_deref().map(|s| s.to_string()); 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); tracing::error!("failed to build window: {}", e);
} }
}); });
@ -279,6 +339,7 @@ fn build_app_window(
app: &gtk4::Application, app: &gtk4::Application,
cfg: Config, cfg: Config,
initial_view: Option<String>, initial_view: Option<String>,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> { ) -> Result<()> {
apply_css(&cfg); apply_css(&cfg);
@ -465,6 +526,10 @@ fn build_app_window(
} }
stack.set_visible_child_name(initial); stack.set_visible_child_name(initial);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req);
}
window.present(); window.present();
Ok(()) Ok(())
} }

View file

@ -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 <view>` 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: &gtk4::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);
}
}
}