Wire breadshot into the bread event bus (app id shot)
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s

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 …").
This commit is contained in:
Breadway 2026-08-15 22:15:03 +08:00
parent 5d1b8918d3
commit c68214a6ab
4 changed files with 159 additions and 1 deletions

12
Cargo.lock generated
View file

@ -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",

View file

@ -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"

66
EVENTS.md Normal file
View file

@ -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": <string or null> }` | 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 <mode>` 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.

View file

@ -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");
}
}