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.
This commit is contained in:
parent
ed371964c2
commit
b89e349342
7 changed files with 359 additions and 56 deletions
27
EVENTS.md
27
EVENTS.md
|
|
@ -22,16 +22,20 @@ received while `breadshot listen` is running — that process holds the
|
||||||
|
|
||||||
| Event | Data | When |
|
| 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), 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": <string or null> }` | 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.done` | `{ "clipboard": true, "path": null }` | `bread.command.shot.region` was received and the region capture succeeded. |
|
||||||
| `bread.shot.region.failed` | `{ "error": "<message>" }` | `bread.command.shot.region` was received but the capture failed (cancelled slurp, missing dependency, grim/wl-copy error). |
|
| `bread.shot.region.failed` | `{ "error": "<message>" }` | `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": <string or null> }` | `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": "<message>" }` | `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 <mode>` 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
|
`clipboard` is whether the PNG was written to the clipboard — both current
|
||||||
capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is
|
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
|
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
|
disk) or the annotator exited without writing one. The listen-triggered
|
||||||
always `null` on `bread.shot.region.done`.
|
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
|
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
|
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 |
|
| 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. |
|
| `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
|
```lua
|
||||||
bread.spawn(function()
|
bread.spawn(function()
|
||||||
bread.emit("bread.command.shot.region")
|
bread.emit("bread.command.shot.region")
|
||||||
bread.wait("bread.shot.region.done", { timeout = 30000 })
|
bread.wait("bread.shot.region.done", { timeout = 30000 })
|
||||||
end)
|
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
|
A workflow that wants a file on disk (not just the clipboard) should
|
||||||
|
|
@ -60,16 +70,17 @@ still shell out:
|
||||||
```lua
|
```lua
|
||||||
bread.exec("breadshot region")
|
bread.exec("breadshot region")
|
||||||
bread.exec("breadshot active-output")
|
bread.exec("breadshot active-output")
|
||||||
|
bread.exec("breadshot annotate")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Not implemented: extra verbs
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
There is no `window`, `output`, `active-window`, `active-output`, `pin`,
|
There is no `window`, `output`, `active-window`, `active-output`, `pin`,
|
||||||
`select`, or `edit` command verb. The CLI already covers the other
|
`select`, or `edit` command verb. The CLI already covers the other
|
||||||
capture modes as synchronous one-shots, and breadshot has no editor, no
|
capture modes as synchronous one-shots. Annotation is the `annotate`
|
||||||
history, and no concept those other verbs could hang on. If/when that
|
verb (a thin satty/swappy hand-off), not a built-in editor — do not
|
||||||
changes, the corresponding `bread.command.shot.*` verb should be added
|
merge this with `bread-screenshots`. If/when another verb is needed,
|
||||||
at the same time, not stubbed as a no-op ahead of it.
|
add it at the same time, not stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
## Fail-safe behavior
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -28,6 +28,8 @@ Required (must be in `$PATH`):
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `hyprpicker` — screen freeze during selection (`--freeze`)
|
- `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)
|
- `notify-send` — desktop notifications (silently skipped if absent)
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
@ -47,13 +49,15 @@ make install PREFIX=/usr
|
||||||
|
|
||||||
```
|
```
|
||||||
breadshot <mode> [options]
|
breadshot <mode> [options]
|
||||||
|
breadshot annotate [options]
|
||||||
breadshot listen
|
breadshot listen
|
||||||
```
|
```
|
||||||
|
|
||||||
`breadshot listen` is the long-running process that honors
|
`breadshot listen` is the long-running process that honors
|
||||||
`bread.command.shot.region` on the bread event bus (clipboard-only
|
`bread.command.shot.region` (clipboard-only region capture) and
|
||||||
region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still
|
`bread.command.shot.annotate` (region capture, then freeze-and-annotate)
|
||||||
works; bus commands are a silent no-op.
|
on the bread event bus. See [EVENTS.md](EVENTS.md). Without it, the CLI
|
||||||
|
still works; bus commands are a silent no-op.
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
|
|
||||||
|
|
@ -64,6 +68,7 @@ works; bus commands are a silent no-op.
|
||||||
| `output` | Click to select a monitor |
|
| `output` | Click to select a monitor |
|
||||||
| `active-window` | Capture the currently focused window |
|
| `active-window` | Capture the currently focused window |
|
||||||
| `active-output` | Capture the monitor containing the active workspace |
|
| `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
|
### 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 |
|
| `--clipboard-only` | `-c` | Copy to clipboard only, do not save to disk |
|
||||||
| `--silent` | `-s` | Suppress notifications |
|
| `--silent` | `-s` | Suppress notifications |
|
||||||
| `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) |
|
| `--freeze` | `-z` | Freeze screen during selection (requires `hyprpicker`) |
|
||||||
|
| `--annotate` | `-a` | Freeze the captured frame and annotate (requires `satty` or `swappy`) |
|
||||||
| `--output-dir <DIR>` | `-o` | Override the save directory from config |
|
| `--output-dir <DIR>` | `-o` | Override the save directory from config |
|
||||||
| `--filename <NAME>` | `-f` | Override the output filename (without path) |
|
| `--filename <NAME>` | `-f` | Override the output filename (without path) |
|
||||||
| `--config <FILE>` | | Use a specific config file |
|
| `--config <FILE>` | | 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
|
### Examples
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -87,6 +97,11 @@ breadshot active-window --clipboard-only
|
||||||
|
|
||||||
# region selection with screen frozen, saved to a custom path
|
# region selection with screen frozen, saved to a custom path
|
||||||
breadshot region --freeze --output-dir ~/Desktop --filename capture.png
|
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
|
## Configuration
|
||||||
|
|
@ -105,6 +120,9 @@ silent = false
|
||||||
# Freeze screen during selection by default (requires hyprpicker)
|
# Freeze screen during selection by default (requires hyprpicker)
|
||||||
freeze = false
|
freeze = false
|
||||||
|
|
||||||
|
# Freeze the captured frame and annotate by default (requires satty or swappy)
|
||||||
|
annotate = false
|
||||||
|
|
||||||
# Notification display duration in milliseconds
|
# Notification display duration in milliseconds
|
||||||
notif_timeout = 5000
|
notif_timeout = 5000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ name = "breadshot"
|
||||||
description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry"
|
description = "Wayland screenshot utility for the bread ecosystem — wraps grim/slurp/wl-copy with Hyprland-aware geometry"
|
||||||
binaries = ["breadshot"]
|
binaries = ["breadshot"]
|
||||||
system_deps = ["grim", "slurp", "wl-clipboard"]
|
system_deps = ["grim", "slurp", "wl-clipboard"]
|
||||||
optional_system_deps = ["hyprland", "hyprpicker", "libnotify"]
|
optional_system_deps = ["hyprland", "hyprpicker", "libnotify", "satty", "swappy"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
|
||||||
[config]
|
[config]
|
||||||
|
|
|
||||||
221
src/capture.rs
221
src/capture.rs
|
|
@ -44,11 +44,35 @@ pub struct Overrides {
|
||||||
pub clipboard_only: bool,
|
pub clipboard_only: bool,
|
||||||
pub silent: bool,
|
pub silent: bool,
|
||||||
pub freeze: bool,
|
pub freeze: bool,
|
||||||
|
pub annotate: bool,
|
||||||
pub output_dir: Option<PathBuf>,
|
pub output_dir: Option<PathBuf>,
|
||||||
pub filename: Option<String>,
|
pub filename: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<CaptureOutcome> {
|
||||||
check_deps()?;
|
check_deps()?;
|
||||||
|
|
||||||
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
|
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
|
||||||
|
|
@ -63,7 +87,11 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
||||||
let silent = overrides.silent || config.silent;
|
let silent = overrides.silent || config.silent;
|
||||||
let freeze = overrides.freeze || config.freeze;
|
let freeze = overrides.freeze || config.freeze;
|
||||||
let clipboard_only = overrides.clipboard_only;
|
let clipboard_only = overrides.clipboard_only;
|
||||||
|
let annotator = resolve_annotator(overrides.annotate || config.annotate);
|
||||||
|
|
||||||
|
// 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 {
|
let _freeze_guard = if freeze {
|
||||||
FreezeGuard::try_spawn()
|
FreezeGuard::try_spawn()
|
||||||
.map_err(|e| tracing::warn!("freeze: {e}"))
|
.map_err(|e| tracing::warn!("freeze: {e}"))
|
||||||
|
|
@ -75,27 +103,45 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
||||||
let geometry = geometry_for_mode(mode)?;
|
let geometry = geometry_for_mode(mode)?;
|
||||||
tracing::debug!("geometry: {geometry}");
|
tracing::debug!("geometry: {geometry}");
|
||||||
|
|
||||||
if clipboard_only {
|
if annotator.is_some() {
|
||||||
|
Some(grim_png(&geometry)?)
|
||||||
|
} else if clipboard_only {
|
||||||
copy_only(&geometry)?;
|
copy_only(&geometry)?;
|
||||||
|
None
|
||||||
} else {
|
} else {
|
||||||
std::fs::create_dir_all(save_dir)
|
std::fs::create_dir_all(save_dir)
|
||||||
.with_context(|| format!("creating {}", save_dir.display()))?;
|
.with_context(|| format!("creating {}", save_dir.display()))?;
|
||||||
save_and_copy(&geometry, &save_path)?;
|
save_and_copy(&geometry, &save_path)?;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
// Both capture paths copy the PNG to the clipboard. `path` is null
|
||||||
// when the user asked for clipboard-only (no file on disk).
|
// when the user asked for clipboard-only (no file on disk) or the
|
||||||
emit_captured(
|
// annotator exited without writing the save file.
|
||||||
mode,
|
let path = if clipboard_only || !save_path.exists() {
|
||||||
true,
|
|
||||||
if clipboard_only {
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(save_path.as_path())
|
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 {
|
let msg = if clipboard_only {
|
||||||
"Copied to clipboard".to_string()
|
"Copied to clipboard".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -104,7 +150,10 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
||||||
send_notification("Screenshot", &msg, config.notif_timeout, &save_path);
|
send_notification("Screenshot", &msg, config.notif_timeout, &save_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(CaptureOutcome {
|
||||||
|
clipboard: true,
|
||||||
|
path: path.map(Path::to_path_buf),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- geometry ---
|
// --- geometry ---
|
||||||
|
|
@ -263,6 +312,103 @@ fn trim_geometry(geometry: &str) -> Result<String> {
|
||||||
|
|
||||||
// --- capture ---
|
// --- capture ---
|
||||||
|
|
||||||
|
fn grim_png(geometry: &str) -> Result<Vec<u8>> {
|
||||||
|
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<Annotator> {
|
||||||
|
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<Annotator> {
|
||||||
|
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<String> {
|
||||||
|
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<()> {
|
fn copy_only(geometry: &str) -> Result<()> {
|
||||||
let mut grim = Command::new("grim")
|
let mut grim = Command::new("grim")
|
||||||
.args(["-g", geometry, "-"])
|
.args(["-g", geometry, "-"])
|
||||||
|
|
@ -464,4 +610,57 @@ mod tests {
|
||||||
assert_eq!(v["clipboard"], true);
|
assert_eq!(v["clipboard"], true);
|
||||||
assert_eq!(v["path"], "/tmp/shot.png");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ pub struct Config {
|
||||||
pub save_dir: PathBuf,
|
pub save_dir: PathBuf,
|
||||||
pub silent: bool,
|
pub silent: bool,
|
||||||
pub freeze: bool,
|
pub freeze: bool,
|
||||||
|
/// Open satty/swappy after capture to annotate the frozen frame.
|
||||||
|
pub annotate: bool,
|
||||||
pub notif_timeout: u32,
|
pub notif_timeout: u32,
|
||||||
pub date_format: String,
|
pub date_format: String,
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +22,7 @@ impl Default for Config {
|
||||||
.join("Screenshots"),
|
.join("Screenshots"),
|
||||||
silent: false,
|
silent: false,
|
||||||
freeze: false,
|
freeze: false,
|
||||||
|
annotate: false,
|
||||||
notif_timeout: 5000,
|
notif_timeout: 5000,
|
||||||
date_format: "%Y-%m-%d-%H%M%S".to_string(),
|
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());
|
tracing::debug!("no config at {}, using defaults", path.display());
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(path)
|
let content =
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
let mut config: Self = toml::from_str(&content)
|
let mut config: Self =
|
||||||
.with_context(|| format!("parsing {}", path.display()))?;
|
toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
|
||||||
config.save_dir = expand_tilde(config.save_dir);
|
config.save_dir = expand_tilde(config.save_dir);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
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;
|
use crate::config::Config;
|
||||||
|
|
||||||
/// Subscribe to `bread.command.shot.**` and block until the process is killed.
|
/// 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 —
|
/// Reacts to `bread.command.shot.*` verbs. Only `region` and `annotate` are
|
||||||
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
/// honored — other verbs are ignored, not stubbed as no-ops that pretend
|
||||||
|
/// to succeed.
|
||||||
///
|
///
|
||||||
/// Emits `bread.shot.region.done` / `.failed` per the confirmation convention
|
/// Emits `bread.shot.<verb>.done` / `.failed` per the confirmation
|
||||||
/// in bread's Documentation.md.
|
/// convention in bread's Documentation.md.
|
||||||
fn handle_command(event: &BreadEvent, config: &Config) {
|
fn handle_command(event: &BreadEvent, config: &Config) {
|
||||||
let Some(verb) = command_verb(&event.event) else {
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match verb {
|
match verb {
|
||||||
"region" => handle_region(config),
|
"region" => handle_region(config),
|
||||||
|
"annotate" => handle_annotate(config),
|
||||||
other => {
|
other => {
|
||||||
tracing::debug!("ignoring unrecognized command verb '{other}'");
|
tracing::debug!("ignoring unrecognized command verb '{other}'");
|
||||||
}
|
}
|
||||||
|
|
@ -57,16 +59,43 @@ fn handle_region(config: &Config) {
|
||||||
clipboard_only: true,
|
clipboard_only: true,
|
||||||
silent: false,
|
silent: false,
|
||||||
freeze: false,
|
freeze: false,
|
||||||
|
annotate: false,
|
||||||
output_dir: None,
|
output_dir: None,
|
||||||
filename: None,
|
filename: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let client = BreadClient::connect(APP_ID);
|
let client = BreadClient::connect(APP_ID);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => client.emit("bread.shot.region.done", region_done_payload()),
|
Ok(_) => client.emit("bread.shot.region.done", region_done_payload()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("bread.command.shot.region failed: {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 })
|
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() })
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -90,6 +126,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn command_verb_strips_shot_prefix() {
|
fn command_verb_strips_shot_prefix() {
|
||||||
assert_eq!(command_verb("bread.command.shot.region"), Some("region"));
|
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.shot.window"), Some("window"));
|
||||||
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
||||||
assert_eq!(command_verb("bread.shot.captured"), None);
|
assert_eq!(command_verb("bread.shot.captured"), None);
|
||||||
|
|
@ -103,8 +143,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn region_failed_payload_includes_error() {
|
fn command_failed_payload_includes_error() {
|
||||||
let v = region_failed_payload(&"selection cancelled");
|
let v = command_failed_payload(&"selection cancelled");
|
||||||
assert_eq!(v["error"], "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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/main.rs
16
src/main.rs
|
|
@ -40,6 +40,10 @@ struct CaptureOpts {
|
||||||
#[arg(long, short = 'z')]
|
#[arg(long, short = 'z')]
|
||||||
freeze: bool,
|
freeze: bool,
|
||||||
|
|
||||||
|
/// Freeze the captured frame and annotate (requires satty or swappy)
|
||||||
|
#[arg(long, short = 'a')]
|
||||||
|
annotate: bool,
|
||||||
|
|
||||||
/// Override save directory from config
|
/// Override save directory from config
|
||||||
#[arg(long, short = 'o', value_name = "DIR")]
|
#[arg(long, short = 'o', value_name = "DIR")]
|
||||||
output_dir: Option<PathBuf>,
|
output_dir: Option<PathBuf>,
|
||||||
|
|
@ -63,7 +67,9 @@ enum Command {
|
||||||
/// Capture the active monitor
|
/// Capture the active monitor
|
||||||
#[command(name = "active-output")]
|
#[command(name = "active-output")]
|
||||||
ActiveOutput(CaptureOpts),
|
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,
|
Listen,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +81,10 @@ impl Command {
|
||||||
Self::Output(opts) => Some((Mode::Output, opts)),
|
Self::Output(opts) => Some((Mode::Output, opts)),
|
||||||
Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)),
|
Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)),
|
||||||
Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)),
|
Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)),
|
||||||
|
Self::Annotate(mut opts) => {
|
||||||
|
opts.annotate = true;
|
||||||
|
Some((Mode::Region, opts))
|
||||||
|
}
|
||||||
Self::Listen => None,
|
Self::Listen => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,8 +114,10 @@ fn main() -> Result<()> {
|
||||||
clipboard_only: opts.clipboard_only,
|
clipboard_only: opts.clipboard_only,
|
||||||
silent: opts.silent,
|
silent: opts.silent,
|
||||||
freeze: opts.freeze,
|
freeze: opts.freeze,
|
||||||
|
annotate: opts.annotate,
|
||||||
output_dir: opts.output_dir,
|
output_dir: opts.output_dir,
|
||||||
filename: opts.filename,
|
filename: opts.filename,
|
||||||
},
|
},
|
||||||
)
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue