breadbox: add --screenshot CLI mode for automated capture

Same pattern as breadbar's screenshot mode (see bread-ecosystem's
bread-capture orchestrator): render the launcher panel, capture it via
bread-screenshots, then exit. Only one view worth capturing here
("launcher") since breadbox has no separate layer surfaces or popovers —
just the search box + app list over a full-screen transparent overlay —
so a full known-size canvas capture is enough, no geometry tracking
needed.

Also fixes a real footgun in the existing PID-file toggle: it kills
whatever process is holding breadbox.pid, which on this machine is
typically the real, already-running breadbox. A screenshot run now skips
toggle_or_continue() entirely instead of fighting over (and killing) the
operator's real instance.
This commit is contained in:
Breadway 2026-07-29 11:32:37 +08:00
parent a47fd5852c
commit 8d6573590b
4 changed files with 342 additions and 17 deletions

211
Cargo.lock generated
View file

@ -8,6 +8,62 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[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"
@ -26,6 +82,16 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bread-screenshots"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff"
dependencies = [
"anyhow",
"bread-utils",
"tracing",
]
[[package]]
name = "bread-theme"
version = "0.2.3"
@ -37,12 +103,25 @@ dependencies = [
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#27b3b17c58b81f947a40cc0d3f9cd0862e7885ff"
dependencies = [
"dirs",
"serde",
"serde_json",
]
[[package]]
name = "breadbox"
version = "0.2.4"
dependencies = [
"anyhow",
"bread-screenshots",
"bread-theme",
"breadbox-shared",
"clap",
"gtk4",
"gtk4-layer-shell",
"serde_json",
@ -115,6 +194,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 = "crc32fast"
version = "1.5.0"
@ -153,7 +278,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -237,7 +362,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -408,7 +533,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -544,7 +669,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -691,6 +816,12 @@ dependencies = [
"hashbrown",
]
[[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"
@ -761,6 +892,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"
@ -947,7 +1084,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1011,6 +1148,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@ -1028,6 +1171,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 = "synstructure"
version = "0.13.2"
@ -1036,7 +1190,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1075,7 +1229,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]
@ -1180,6 +1334,37 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[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.117",
]
[[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 = "unicode-ident"
version = "1.0.24"
@ -1226,6 +1411,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 = "version-compare"
version = "0.2.1"
@ -1459,7 +1650,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@ -1480,7 +1671,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
"synstructure",
]
@ -1520,7 +1711,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.117",
]
[[package]]

View file

@ -10,7 +10,11 @@ path = "src/main.rs"
[dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" }
breadbox-shared = { path = "../breadbox-shared" }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"

View file

@ -23,6 +23,8 @@ use gtk4::{
};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
mod screenshot;
// ---- Hyprland IPC -----------------------------------------------------------
fn get_active_workspace() -> Option<String> {
@ -295,13 +297,24 @@ fn get_row_entry(row: &gtk4::ListBoxRow) -> Option<DesktopEntry> {
}
}
fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
let app = Application::builder()
.application_id("com.breadway.breadbox")
.build();
fn run_ui(
entries: Vec<DesktopEntry>,
history: LaunchHistory,
screenshot_req: Option<screenshot::ScreenshotRequest>,
) {
let mut builder = Application::builder().application_id("com.breadway.breadbox");
if screenshot_req.is_some() {
// GApplication is single-instance by default; this machine typically
// already has a real breadbox 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 history_rc = Rc::new(RefCell::new(history));
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let is_screenshot_run = screenshot_req.is_some();
app.connect_activate(move |app| {
// Shared ecosystem base (fonts, palette, generic widgets) first, then
@ -539,17 +552,38 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
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 breadbox instance 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 launcher.
if screenshot_req.is_none() && !toggle_or_continue() {
return;
}
@ -564,5 +598,5 @@ fn main() {
let manifest = load_manifest();
let entries = load_sorted_entries(&manifest, &priority, &history);
run_ui(entries, history);
run_ui(entries, history, screenshot_req);
}

View file

@ -0,0 +1,96 @@
//! `--screenshot` CLI mode: render breadbox's launcher panel, capture it via
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
//! `bread-capture` orchestrator, or run standalone for one-off captures.
//!
//! breadbox has only one view worth capturing: the launcher panel itself
//! (search box + app list). It's a `halign: Center` panel over a full-screen
//! transparent overlay window, not its own layer surface, so — same
//! reasoning as breadbar's control-panel view — the simplest reliable
//! capture is the whole known-size canvas, not a hand-tracked panel
//! geometry.
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 = "breadbox")]
pub struct Cli {
/// Render the named view, capture it, then exit instead of running
/// normally. Known views: "launcher".
#[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!("breadbox: --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 launcher UI.
pub fn dispatch(window: &gtk4::ApplicationWindow, req: ScreenshotRequest) {
match req.view.as_str() {
"launcher" => {
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!("breadbox: unknown screenshot view '{other}' (known: launcher)");
std::process::exit(1);
}
}
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("breadbox: screenshot capture failed: {e}");
std::process::exit(1);
}
}
}