From c68214a6ab92d1e4144cebcbe0a22352d42645b7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:15:03 +0800 Subject: [PATCH] Wire breadshot into the bread event bus (app id shot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit bread.shot.captured after a successful grim/wl-copy capture. Fail-silent when breadd is down. No command verbs — breadshot is a one-shot CLI; Lua workflows should bread.exec("breadshot …"). --- Cargo.lock | 12 ++++++++ Cargo.toml | 2 +- EVENTS.md | 66 +++++++++++++++++++++++++++++++++++++++++ src/capture.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 EVENTS.md diff --git a/Cargo.lock b/Cargo.lock index c1bb34d..2f2acd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,11 +82,23 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml", +] + [[package]] name = "bread-utils" version = "0.3.1" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ + "bread-shared", "dirs", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a747e4d..5d7286c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = "1" toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..abc2eb8 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,66 @@ +# breadshot — bread event integration + +breadshot is a standalone, one-shot Wayland screenshot orchestrator: it +works exactly the same with or without `breadd` running. When breadd *is* +present, a successful capture publishes one event into the shared bread +automation fabric. See the parent `bread` repo's `Documentation.md` — +specifically its "Namespaces" and "Integrating a bread\* app" sections — +for the general convention this follows. + +This is a different job from `bread-screenshots` (the crate in +`bread-ecosystem`): that one is a capture harness for screenshotting +sibling apps in CI. Do not merge the two. + +App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`). breadshot is a short-lived CLI, not a daemon — +each `emit` is its own fire-and-forget connection (the same stance +`bread-emit` takes for occasional callers). There is no process to hold a +command subscription open. + +## Events published (`bread.shot.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": }` | A capture completed successfully (grim + clipboard write both returned). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | + +`mode` is the CLI mode name (same strings `breadshot ` accepts). +`clipboard` is whether the PNG was written to the clipboard — both current +capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is +the saved file, or `null` when `--clipboard-only` was used (no file on +disk). + +The image bytes themselves are never included in the payload. The event +bus is a notification that a capture happened, not a channel for the +screenshot. + +## Commands honored (`bread.command.shot.*`) + +None. breadshot is a one-shot CLI with no persistent process to subscribe +to `bread.command.shot.*`. A Lua workflow that wants a screenshot should +shell out: + +```lua +bread.exec("breadshot region") +-- or +bread.exec("breadshot region --clipboard-only") +bread.exec("breadshot active-output") +``` + +The outcome of that exec is the same `bread.shot.captured` event the +keybind path already publishes — `bread.wait("bread.shot.captured")` +inside a spawned coroutine if the workflow needs to react to the file. + +There is no `pin`, `select`, `edit`, or other command verb. breadshot has +no editor, no history, and no concept those verbs could hang on. +`bread.exec("breadshot …")` is the whole command surface. If/when a +long-running piece exists, verbs should be added then, not stubbed as +no-ops ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) — breadshot's + actual grim/slurp/wl-copy path is entirely unaffected either way. +- There is no command subscription, so a breadd restart cannot drop one. + The next `breadshot` invocation emits (or silently doesn't) on its own + short-lived connection. diff --git a/src/capture.rs b/src/capture.rs index 349dde9..dd35f2f 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -10,6 +10,10 @@ use std::{ use crate::config::Config; +/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as +/// `bread.shot.*`. See `EVENTS.md`. +const APP_ID: &str = "shot"; + #[derive(Debug, Clone, ValueEnum)] pub enum Mode { /// Select a region interactively @@ -26,6 +30,19 @@ pub enum Mode { ActiveOutput, } +impl Mode { + /// CLI / event-payload name (`region`, `active-window`, …). + pub fn as_str(&self) -> &'static str { + match self { + Self::Region => "region", + Self::Window => "window", + Self::Output => "output", + Self::ActiveWindow => "active-window", + Self::ActiveOutput => "active-output", + } + } +} + pub struct Overrides { pub clipboard_only: bool, pub silent: bool, @@ -69,6 +86,18 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { save_and_copy(&geometry, &save_path)?; } + // Both capture paths copy the PNG to the clipboard. `path` is null + // when the user asked for clipboard-only (no file on disk). + emit_captured( + mode, + true, + if clipboard_only { + None + } else { + Some(save_path.as_path()) + }, + ); + if !silent { let msg = if clipboard_only { "Copied to clipboard".to_string() @@ -289,6 +318,26 @@ fn save_and_copy(geometry: &str, path: &Path) -> Result<()> { Ok(()) } +/// Publishes `bread.shot.captured` into the bread event fabric. Fire-and-forget +/// and non-fatal by design (`BreadClient::emit` never blocks or errors this +/// caller) — breadd being absent or not installed must never affect +/// breadshot's own capture path, only mean this one notification doesn't +/// go anywhere. +fn emit_captured(mode: &Mode, clipboard: bool, path: Option<&Path>) { + bread_utils::bread_client::BreadClient::connect(APP_ID).emit( + "bread.shot.captured", + captured_payload(mode, clipboard, path), + ); +} + +fn captured_payload(mode: &Mode, clipboard: bool, path: Option<&Path>) -> serde_json::Value { + serde_json::json!({ + "mode": mode.as_str(), + "clipboard": clipboard, + "path": path.map(|p| p.to_string_lossy().into_owned()), + }) +} + fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) { let mut cmd = Command::new("notify-send"); cmd.args([title, msg, "-t", &timeout.to_string(), "-a", "breadshot"]); @@ -384,3 +433,34 @@ impl Drop for FreezeGuard { let _ = self.child.wait(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mode_as_str_matches_cli_names() { + assert_eq!(Mode::Region.as_str(), "region"); + assert_eq!(Mode::Window.as_str(), "window"); + assert_eq!(Mode::Output.as_str(), "output"); + assert_eq!(Mode::ActiveWindow.as_str(), "active-window"); + assert_eq!(Mode::ActiveOutput.as_str(), "active-output"); + } + + #[test] + fn captured_payload_clipboard_only_has_null_path() { + let v = captured_payload(&Mode::Region, true, None); + assert_eq!(v["mode"], "region"); + assert_eq!(v["clipboard"], true); + assert!(v["path"].is_null()); + } + + #[test] + fn captured_payload_saved_file_includes_path() { + let path = Path::new("/tmp/shot.png"); + let v = captured_payload(&Mode::ActiveOutput, true, Some(path)); + assert_eq!(v["mode"], "active-output"); + assert_eq!(v["clipboard"], true); + assert_eq!(v["path"], "/tmp/shot.png"); + } +}