From b89e349342016e46bb88ae243eda2ed3c12aca44 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:55:36 +0800 Subject: [PATCH] Add freeze-frame annotate via optional satty/swappy After grim+slurp, satty (preferred) or swappy freezes the captured frame for arrows/text/rect. New `breadshot annotate` / --annotate and bread.command.shot.annotate. Missing tools warn and fall back to the existing capture path. listen still honors region. --- EVENTS.md | 27 ++++-- README.md | 24 ++++- bakery.toml | 2 +- src/capture.rs | 255 +++++++++++++++++++++++++++++++++++++++++++------ src/config.rs | 11 ++- src/listen.rs | 80 ++++++++++++++-- src/main.rs | 16 +++- 7 files changed, 359 insertions(+), 56 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 5b7ed86..a74da00 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -22,16 +22,20 @@ received while `breadshot listen` is running — that process holds the | 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), whether triggered by the CLI or by `bread.command.shot.region`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": }` | A capture completed successfully (grim + clipboard write both returned), whether triggered by the CLI or by `bread.command.shot.region` / `bread.command.shot.annotate`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. `breadshot annotate` publishes `mode: "region"`. | | `bread.shot.region.done` | `{ "clipboard": true, "path": null }` | `bread.command.shot.region` was received and the region capture succeeded. | | `bread.shot.region.failed` | `{ "error": "" }` | `bread.command.shot.region` was received but the capture failed (cancelled slurp, missing dependency, grim/wl-copy error). | +| `bread.shot.annotate.done` | `{ "clipboard": true, "path": }` | `bread.command.shot.annotate` was received and the region capture (plus optional satty/swappy pass) succeeded. `path` is the saved file, or `null` if the annotator exited without writing it. | +| `bread.shot.annotate.failed` | `{ "error": "" }` | `bread.command.shot.annotate` was received but the capture failed (cancelled slurp, missing grim/slurp, annotator error). Missing satty/swappy is not a failure — breadshot warns and falls back to grim+slurp. | -`mode` is the CLI mode name (same strings `breadshot ` accepts). +`mode` is the CLI capture-mode name (`region`, `window`, `output`, +`active-window`, `active-output`) — not the `annotate` subcommand. `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 listen-triggered region path is clipboard-only, so `path` is -always `null` on `bread.shot.region.done`. +disk) or the annotator exited without writing one. The listen-triggered +region path is clipboard-only, so `path` is always `null` on +`bread.shot.region.done`. 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 @@ -46,12 +50,18 @@ bread convention, not a breadshot bug. | Verb | Data | Effect | |------|------|--------| | `region` | none | Same interactive region capture as `breadshot region --clipboard-only`. Emits `bread.shot.region.done`/`.failed`. A successful capture also publishes `bread.shot.captured` the same way the CLI path does. | +| `annotate` | none | Same as `breadshot annotate`: region capture, then freeze the frame in `satty` (preferred) or `swappy` for arrows/text/rect. Emits `bread.shot.annotate.done`/`.failed`. A successful capture also publishes `bread.shot.captured` (`mode: "region"`). If neither annotator is installed, breadshot warns and saves the unannotated region shot. | ```lua bread.spawn(function() bread.emit("bread.command.shot.region") bread.wait("bread.shot.region.done", { timeout = 30000 }) end) + +bread.spawn(function() + bread.emit("bread.command.shot.annotate") + bread.wait("bread.shot.annotate.done", { timeout = 120000 }) +end) ``` A workflow that wants a file on disk (not just the clipboard) should @@ -60,16 +70,17 @@ still shell out: ```lua bread.exec("breadshot region") bread.exec("breadshot active-output") +bread.exec("breadshot annotate") ``` ### Not implemented: extra verbs There is no `window`, `output`, `active-window`, `active-output`, `pin`, `select`, or `edit` command verb. The CLI already covers the other -capture modes as synchronous one-shots, and breadshot has no editor, no -history, and no concept those other verbs could hang on. If/when that -changes, the corresponding `bread.command.shot.*` verb should be added -at the same time, not stubbed as a no-op ahead of it. +capture modes as synchronous one-shots. Annotation is the `annotate` +verb (a thin satty/swappy hand-off), not a built-in editor — do not +merge this with `bread-screenshots`. If/when another verb is needed, +add it at the same time, not stubbed as a no-op ahead of it. ## Fail-safe behavior diff --git a/README.md b/README.md index 1fdefc1..77a7575 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Required (must be in `$PATH`): Optional: - `hyprpicker` — screen freeze during selection (`--freeze`) +- `satty` — freeze the captured frame and annotate (arrows/text/rect). Preferred for `--annotate` +- `swappy` — fallback annotator if `satty` is missing - `notify-send` — desktop notifications (silently skipped if absent) ## Build and install @@ -47,13 +49,15 @@ make install PREFIX=/usr ``` breadshot [options] +breadshot annotate [options] breadshot listen ``` `breadshot listen` is the long-running process that honors -`bread.command.shot.region` on the bread event bus (clipboard-only -region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still -works; bus commands are a silent no-op. +`bread.command.shot.region` (clipboard-only region capture) and +`bread.command.shot.annotate` (region capture, then freeze-and-annotate) +on the bread event bus. See [EVENTS.md](EVENTS.md). Without it, the CLI +still works; bus commands are a silent no-op. ### Modes @@ -64,6 +68,7 @@ works; bus commands are a silent no-op. | `output` | Click to select a monitor | | `active-window` | Capture the currently focused window | | `active-output` | Capture the monitor containing the active workspace | +| `annotate` | Region capture, then freeze the frame for arrows/text/rect (requires `satty` or `swappy`) | ### Options @@ -72,10 +77,15 @@ works; bus commands are a silent no-op. | `--clipboard-only` | `-c` | Copy to clipboard only, do not save to disk | | `--silent` | `-s` | Suppress notifications | | `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) | +| `--annotate` | `-a` | Freeze the captured frame and annotate (requires `satty` or `swappy`) | | `--output-dir ` | `-o` | Override the save directory from config | | `--filename ` | `-f` | Override the output filename (without path) | | `--config ` | | Use a specific config file | +If `--annotate` is set (or `breadshot annotate` is used) but neither +`satty` nor `swappy` is in `$PATH`, breadshot prints a warning and +falls back to the normal grim+slurp capture. + ### Examples ```sh @@ -87,6 +97,11 @@ breadshot active-window --clipboard-only # region selection with screen frozen, saved to a custom path breadshot region --freeze --output-dir ~/Desktop --filename capture.png + +# region capture, then freeze the frame and annotate +breadshot annotate +breadshot region --annotate +breadshot output --annotate ``` ## Configuration @@ -105,6 +120,9 @@ silent = false # Freeze screen during selection by default (requires hyprpicker) freeze = false +# Freeze the captured frame and annotate by default (requires satty or swappy) +annotate = false + # Notification display duration in milliseconds notif_timeout = 5000 diff --git a/bakery.toml b/bakery.toml index 616d788..abcd841 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,7 +2,7 @@ name = "breadshot" description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry" binaries = ["breadshot"] system_deps = ["grim", "slurp", "wl-clipboard"] -optional_system_deps = ["hyprland", "hyprpicker", "libnotify"] +optional_system_deps = ["hyprland", "hyprpicker", "libnotify", "satty", "swappy"] bread_deps = [] [config] diff --git a/src/capture.rs b/src/capture.rs index 73729b4..1422359 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -44,11 +44,35 @@ pub struct Overrides { pub clipboard_only: bool, pub silent: bool, pub freeze: bool, + pub annotate: bool, pub output_dir: Option, pub filename: Option, } -pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { +/// What a successful capture left behind. `path` is `None` when the user +/// asked for clipboard-only, cancelled the annotator without saving, or +/// the save file was never written. +pub struct CaptureOutcome { + pub clipboard: bool, + pub path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Annotator { + Satty, + Swappy, +} + +impl Annotator { + fn name(self) -> &'static str { + match self { + Self::Satty => "satty", + Self::Swappy => "swappy", + } + } +} + +pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result { check_deps()?; let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir); @@ -63,39 +87,61 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { let silent = overrides.silent || config.silent; let freeze = overrides.freeze || config.freeze; let clipboard_only = overrides.clipboard_only; + let annotator = resolve_annotator(overrides.annotate || config.annotate); - let _freeze_guard = if freeze { - FreezeGuard::try_spawn() - .map_err(|e| tracing::warn!("freeze: {e}")) - .ok() - } else { - None + // Capture under the optional hyprpicker freeze, then drop it before + // the annotator window appears so it can take the frozen frame. + let captured_png = { + let _freeze_guard = if freeze { + FreezeGuard::try_spawn() + .map_err(|e| tracing::warn!("freeze: {e}")) + .ok() + } else { + None + }; + + let geometry = geometry_for_mode(mode)?; + tracing::debug!("geometry: {geometry}"); + + if annotator.is_some() { + Some(grim_png(&geometry)?) + } else if clipboard_only { + copy_only(&geometry)?; + None + } else { + std::fs::create_dir_all(save_dir) + .with_context(|| format!("creating {}", save_dir.display()))?; + save_and_copy(&geometry, &save_path)?; + None + } }; - let geometry = geometry_for_mode(mode)?; - tracing::debug!("geometry: {geometry}"); - - if clipboard_only { - copy_only(&geometry)?; - } else { - std::fs::create_dir_all(save_dir) - .with_context(|| format!("creating {}", save_dir.display()))?; - save_and_copy(&geometry, &save_path)?; + if let (Some(tool), Some(png)) = (annotator, captured_png) { + if !clipboard_only { + std::fs::create_dir_all(save_dir) + .with_context(|| format!("creating {}", save_dir.display()))?; + } + run_annotator( + tool, + &png, + (!clipboard_only).then_some(save_path.as_path()), + silent, + )?; } // 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()) - }, - ); + // when the user asked for clipboard-only (no file on disk) or the + // annotator exited without writing the save file. + let path = if clipboard_only || !save_path.exists() { + None + } else { + Some(save_path.as_path()) + }; - if !silent { + emit_captured(mode, true, path); + + // The annotator owns its own copy/save notifications. + if !silent && annotator.is_none() { let msg = if clipboard_only { "Copied to clipboard".to_string() } else { @@ -104,7 +150,10 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> { send_notification("Screenshot", &msg, config.notif_timeout, &save_path); } - Ok(()) + Ok(CaptureOutcome { + clipboard: true, + path: path.map(Path::to_path_buf), + }) } // --- geometry --- @@ -263,6 +312,103 @@ fn trim_geometry(geometry: &str) -> Result { // --- capture --- +fn grim_png(geometry: &str) -> Result> { + let out = Command::new("grim") + .args(["-g", geometry, "-"]) + .output() + .context("running grim")?; + if !out.status.success() { + bail!("grim exited with {}", out.status); + } + Ok(out.stdout) +} + +fn resolve_annotator(requested: bool) -> Option { + if !requested { + return None; + } + match find_annotator() { + Some(tool) => Some(tool), + None => { + eprintln!("breadshot: satty or swappy not found; capturing without annotation"); + eprintln!( + "install satty (preferred) or swappy to freeze the frame and annotate (arrows/text/rect)" + ); + tracing::warn!("annotate requested but satty/swappy missing"); + None + } + } +} + +fn find_annotator() -> Option { + if in_path("satty") { + Some(Annotator::Satty) + } else if in_path("swappy") { + Some(Annotator::Swappy) + } else { + None + } +} + +fn annotator_args(tool: Annotator, save_path: Option<&Path>, silent: bool) -> Vec { + match tool { + Annotator::Satty => { + let mut args = vec![ + "--filename".into(), + "-".into(), + "--fullscreen".into(), + "--copy-command".into(), + "wl-copy".into(), + ]; + if let Some(path) = save_path { + args.push("--output-filename".into()); + args.push(path.to_string_lossy().into_owned()); + args.push("--save-after-copy".into()); + } + if silent { + args.push("--disable-notifications".into()); + } + // Last so a value-taking satty does not swallow the next flag. + args.push("--early-exit".into()); + args + } + Annotator::Swappy => { + let mut args = vec!["-f".into(), "-".into()]; + if let Some(path) = save_path { + args.push("-o".into()); + args.push(path.to_string_lossy().into_owned()); + } + args + } + } +} + +fn run_annotator( + tool: Annotator, + png: &[u8], + save_path: Option<&Path>, + silent: bool, +) -> Result<()> { + let mut child = Command::new(tool.name()) + .args(annotator_args(tool, save_path, silent)) + .stdin(Stdio::piped()) + .spawn() + .with_context(|| format!("spawning {}", tool.name()))?; + + child + .stdin + .take() + .context("annotator stdin")? + .write_all(png) + .context("piping screenshot to annotator")?; + + let status = child.wait().context("waiting for annotator")?; + if !status.success() { + bail!("{} exited with {status}", tool.name()); + } + Ok(()) +} + fn copy_only(geometry: &str) -> Result<()> { let mut grim = Command::new("grim") .args(["-g", geometry, "-"]) @@ -464,4 +610,57 @@ mod tests { assert_eq!(v["clipboard"], true); assert_eq!(v["path"], "/tmp/shot.png"); } + + #[test] + fn satty_args_fullscreen_copy_and_early_exit() { + let args = annotator_args(Annotator::Satty, None, false); + assert_eq!( + args, + [ + "--filename", + "-", + "--fullscreen", + "--copy-command", + "wl-copy", + "--early-exit" + ] + ); + } + + #[test] + fn satty_args_save_path_and_silent() { + let path = Path::new("/tmp/shot.png"); + let args = annotator_args(Annotator::Satty, Some(path), true); + assert_eq!( + args, + [ + "--filename", + "-", + "--fullscreen", + "--copy-command", + "wl-copy", + "--output-filename", + "/tmp/shot.png", + "--save-after-copy", + "--disable-notifications", + "--early-exit" + ] + ); + } + + #[test] + fn swappy_args_stdin_and_optional_output() { + assert_eq!(annotator_args(Annotator::Swappy, None, true), ["-f", "-"]); + let path = Path::new("/tmp/shot.png"); + assert_eq!( + annotator_args(Annotator::Swappy, Some(path), false), + ["-f", "-", "-o", "/tmp/shot.png"] + ); + } + + #[test] + fn annotator_prefers_satty_name() { + assert_eq!(Annotator::Satty.name(), "satty"); + assert_eq!(Annotator::Swappy.name(), "swappy"); + } } diff --git a/src/config.rs b/src/config.rs index eb89f47..5cbb6cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,8 @@ pub struct Config { pub save_dir: PathBuf, pub silent: bool, pub freeze: bool, + /// Open satty/swappy after capture to annotate the frozen frame. + pub annotate: bool, pub notif_timeout: u32, pub date_format: String, } @@ -20,6 +22,7 @@ impl Default for Config { .join("Screenshots"), silent: false, freeze: false, + annotate: false, notif_timeout: 5000, date_format: "%Y-%m-%d-%H%M%S".to_string(), } @@ -36,10 +39,10 @@ impl Config { tracing::debug!("no config at {}, using defaults", path.display()); return Ok(Self::default()); } - let content = std::fs::read_to_string(path) - .with_context(|| format!("reading {}", path.display()))?; - let mut config: Self = toml::from_str(&content) - .with_context(|| format!("parsing {}", path.display()))?; + let content = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let mut config: Self = + toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?; config.save_dir = expand_tilde(config.save_dir); Ok(config) } diff --git a/src/listen.rs b/src/listen.rs index 6b31c6e..d4ed7aa 100644 --- a/src/listen.rs +++ b/src/listen.rs @@ -6,7 +6,7 @@ use anyhow::Result; use bread_utils::bread_client::{BreadClient, BreadEvent}; -use crate::capture::{self, Mode, Overrides, APP_ID}; +use crate::capture::{self, CaptureOutcome, Mode, Overrides, APP_ID}; use crate::config::Config; /// Subscribe to `bread.command.shot.**` and block until the process is killed. @@ -30,17 +30,19 @@ pub fn run(config: &Config) -> Result<()> { } } -/// Reacts to `bread.command.shot.*` verbs. Only `region` is honored today — -/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// Reacts to `bread.command.shot.*` verbs. Only `region` and `annotate` are +/// honored — other verbs are ignored, not stubbed as no-ops that pretend +/// to succeed. /// -/// Emits `bread.shot.region.done` / `.failed` per the confirmation convention -/// in bread's Documentation.md. +/// Emits `bread.shot..done` / `.failed` per the confirmation +/// convention in bread's Documentation.md. fn handle_command(event: &BreadEvent, config: &Config) { let Some(verb) = command_verb(&event.event) else { return; }; match verb { "region" => handle_region(config), + "annotate" => handle_annotate(config), other => { tracing::debug!("ignoring unrecognized command verb '{other}'"); } @@ -57,16 +59,43 @@ fn handle_region(config: &Config) { clipboard_only: true, silent: false, freeze: false, + annotate: false, output_dir: None, filename: None, }, ); let client = BreadClient::connect(APP_ID); match result { - Ok(()) => client.emit("bread.shot.region.done", region_done_payload()), + Ok(_) => client.emit("bread.shot.region.done", region_done_payload()), Err(e) => { tracing::warn!("bread.command.shot.region failed: {e}"); - client.emit("bread.shot.region.failed", region_failed_payload(&e)); + client.emit("bread.shot.region.failed", command_failed_payload(&e)); + } + } +} + +fn handle_annotate(config: &Config) { + // Interactive: satty/swappy get a default save path so the user can + // write the annotated frame. If the annotator is missing, this falls + // back to a normal region save (with a warning on stderr). + let result = capture::run( + &Mode::Region, + config, + Overrides { + clipboard_only: false, + silent: false, + freeze: false, + annotate: true, + output_dir: None, + filename: None, + }, + ); + let client = BreadClient::connect(APP_ID); + match result { + Ok(outcome) => client.emit("bread.shot.annotate.done", annotate_done_payload(&outcome)), + Err(e) => { + tracing::warn!("bread.command.shot.annotate failed: {e}"); + client.emit("bread.shot.annotate.failed", command_failed_payload(&e)); } } } @@ -79,10 +108,17 @@ fn region_done_payload() -> serde_json::Value { serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null }) } -fn region_failed_payload(error: &impl ToString) -> serde_json::Value { +fn command_failed_payload(error: &impl ToString) -> serde_json::Value { serde_json::json!({ "error": error.to_string() }) } +fn annotate_done_payload(outcome: &CaptureOutcome) -> serde_json::Value { + serde_json::json!({ + "clipboard": outcome.clipboard, + "path": outcome.path.as_ref().map(|p| p.to_string_lossy().into_owned()), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -90,6 +126,10 @@ mod tests { #[test] fn command_verb_strips_shot_prefix() { assert_eq!(command_verb("bread.command.shot.region"), Some("region")); + assert_eq!( + command_verb("bread.command.shot.annotate"), + Some("annotate") + ); assert_eq!(command_verb("bread.command.shot.window"), Some("window")); assert_eq!(command_verb("bread.command.clip.clear"), None); assert_eq!(command_verb("bread.shot.captured"), None); @@ -103,8 +143,28 @@ mod tests { } #[test] - fn region_failed_payload_includes_error() { - let v = region_failed_payload(&"selection cancelled"); + fn command_failed_payload_includes_error() { + let v = command_failed_payload(&"selection cancelled"); assert_eq!(v["error"], "selection cancelled"); } + + #[test] + fn annotate_done_payload_includes_saved_path() { + let v = annotate_done_payload(&CaptureOutcome { + clipboard: true, + path: Some(std::path::PathBuf::from("/tmp/shot.png")), + }); + assert_eq!(v["clipboard"], true); + assert_eq!(v["path"], "/tmp/shot.png"); + } + + #[test] + fn annotate_done_payload_null_path_when_unsaved() { + let v = annotate_done_payload(&CaptureOutcome { + clipboard: true, + path: None, + }); + assert_eq!(v["clipboard"], true); + assert!(v["path"].is_null()); + } } diff --git a/src/main.rs b/src/main.rs index 9a474d2..d80cb4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,10 @@ struct CaptureOpts { #[arg(long, short = 'z')] freeze: bool, + /// Freeze the captured frame and annotate (requires satty or swappy) + #[arg(long, short = 'a')] + annotate: bool, + /// Override save directory from config #[arg(long, short = 'o', value_name = "DIR")] output_dir: Option, @@ -63,7 +67,9 @@ enum Command { /// Capture the active monitor #[command(name = "active-output")] ActiveOutput(CaptureOpts), - /// Subscribe to bread.command.shot.** and honor region captures + /// Capture a region, freeze the frame, and annotate + Annotate(CaptureOpts), + /// Subscribe to bread.command.shot.** and honor region/annotate captures Listen, } @@ -75,6 +81,10 @@ impl Command { Self::Output(opts) => Some((Mode::Output, opts)), Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)), Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)), + Self::Annotate(mut opts) => { + opts.annotate = true; + Some((Mode::Region, opts)) + } Self::Listen => None, } } @@ -104,8 +114,10 @@ fn main() -> Result<()> { clipboard_only: opts.clipboard_only, silent: opts.silent, freeze: opts.freeze, + annotate: opts.annotate, output_dir: opts.output_dir, filename: opts.filename, }, - ) + )?; + Ok(()) }