Compare commits

...

19 commits
v0.1.1 ... main

Author SHA1 Message Date
Breadway
9137b4d7c2 CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Successful in 46s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 37s
2026-08-16 00:50:22 +08:00
Breadway
b89e349342 Add freeze-frame annotate via optional satty/swappy
Some checks failed
check / check (push) Failing after 1s
dev release / build (push) Failing after 1s
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.
2026-08-15 23:55:36 +08:00
Breadway
ed371964c2 Bump version to v0.1.2
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Failing after 1s
release / build (push) Failing after 1s
2026-08-15 23:05:47 +08:00
Breadway
365072d65e Honor bread.command.shot.region via breadshot listen
Some checks failed
dev release / build (push) Failing after 0s
Pin bread-utils to bread-ecosystem v0.7.2. breadshot listen
subscribes to bread.command.shot.**, runs the same region capture
as the CLI (clipboard-only), and emits bread.shot.region.done /
.failed.
2026-08-15 22:49:51 +08:00
Breadway
c68214a6ab 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 …").
2026-08-15 22:15:03 +08:00
Breadway
5d1b8918d3 Track AGENTS.md
Some checks failed
check / check (push) Failing after 1s
2026-08-15 22:03:30 +08:00
Breadway
84539f2fcd Pin bread-utils to v0.7.1; document BOS screenshot binds
Some checks failed
check / check (push) Failing after 1s
bread-utils moves from bread-ecosystem tag v0.3.0 to v0.7.1.
README states breadshot is the grim/slurp/wl-copy orchestrator BOS
binds Super+Shift+S/C/P to (not grimblast), not a GUI editor, and
not bread-screenshots.
2026-08-15 21:36:05 +08:00
Breadway
dad2a45d83 Merge fix/shared-ecosystem-ci: shared Arch CI image
All checks were successful
dev release / build (push) Successful in 34s
2026-08-05 14:06:34 +08:00
Breadway
481d460a6e ci: build against bread-ecosystem's shared Arch CI image, add check.yml
All checks were successful
check / check (push) Successful in 18s
Same fix as breadpad: build inside the shared pinned Arch container
(bread-ecosystem/ci/, cloned at the sha in ci/bread-ecosystem.rev)
instead of building natively against whatever's on the runner host.
Adds check.yml (clippy + test on feature/**/fix/**) as a fast-fail gate
before anything reaches main.

Verified locally: build, clippy, and test all pass through the new
container path — no pre-existing lint/test debt found here.
2026-08-05 14:03:18 +08:00
Breadway
3adcfa3167 CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 2m37s
2026-07-31 11:08:41 +08:00
Breadway
773972b711 CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
d06d32b4e6 docs: add CONTRIBUTING.md
All checks were successful
dev release / build (push) Successful in 25s
Documents the dev/beta/main branch and release-track workflow shared
across the bread ecosystem. See bread-ecosystem's docs/release-channels.md
for the full policy this implements.
2026-07-22 19:42:19 +08:00
Breadway
56aecfcbd6 ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
dev release / build (push) Successful in 28s
beta release / build (push) Successful in 21s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:53 +08:00
Breadway
70bb804883 ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
dev release / build (push) Successful in 28s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:53:13 +08:00
Breadway
802e03eecf random commit message, read it yourself
All checks were successful
dev release / build (push) Successful in 46s
2026-07-22 11:57:21 +08:00
Breadway
56251a009c ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
All checks were successful
dev release / build (push) Successful in 55s
The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta
workflows run close together on the same self-hosted runner — one job's
rm -rf/clone can stomp another's in-progress checkout, causing the
regenerate-index step to fail intermittently. Switch to mktemp -d.
2026-07-22 10:24:54 +08:00
Breadway
3b173a6765 ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
All checks were successful
dev release / build (push) Successful in 1m2s
2026-07-22 10:10:22 +08:00
Breadway
86774ab1cf ci: add dev/beta build track workflows
Some checks failed
dev release / build (push) Failing after 45s
Adds dev-release.yml (publishes on every push to dev) and beta-release.yml
(publishes on a beta-v* tag), mirroring the pattern landing in
bread-ecosystem/bread. Also creates the dev branch for this repo, which
didn't exist before — see bread-ecosystem/docs/release-channels.md for the
three-track policy.
2026-07-22 09:56:49 +08:00
Breadway
14672188b7 ci: remove GitHub push-mirror workflow 2026-07-21 19:18:09 +08:00
19 changed files with 1022 additions and 70 deletions

View file

@ -0,0 +1,24 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**']
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --workspace --locked

View file

@ -0,0 +1,75 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk
# branch — there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadshot/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
strip "${PKG_DIR}/breadshot-x86_64"
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadshot-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadshot/latest"
# No GitHub Release upload — dev, like the other non-stable track,
# is only distributed via dl.breadway.dev/dev/.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -1,19 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadshot.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,56 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# pushed to `main` — there is no separate `beta` branch; "freezing" is
# just pausing pushes to main while an RC gets tested. See
# bread-ecosystem's docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadshot/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/breadshot" "${PKG_DIR}/breadshot-x86_64"
strip "${PKG_DIR}/breadshot-x86_64"
sha256sum "${PKG_DIR}/breadshot-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadshot-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadshot/latest"
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -6,6 +6,7 @@ on:
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
@ -16,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked
run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts
run: |
@ -32,8 +42,14 @@ jobs:
ln -sfn "${VERSION}" "/srv/breadway-dl/breadshot/latest"
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

2
.gitignore vendored
View file

@ -29,3 +29,5 @@ logs/
# Runtime files
*.sock
*.pid
# Local hygiene notes (not for commit)

43
AGENTS.md Normal file
View file

@ -0,0 +1,43 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
— read and follow it for any git, branch, or release work here (the
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
etc). Don't improvise a different workflow. The short version: there is one
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
"Freezing" for stabilization means pausing pushes to `main`, not moving a
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
after `main` was found to have silently rotted out of sync with `dev`/`beta`
across most repos in this ecosystem — a manual "merge beta into main
monthly" step nobody reliably did across a dozen-plus repos. Collapsing to
one branch removes the class of bug; there's nothing left that can fall out
of sync.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push both when publishing.
## CI
- `check.yml` — clippy + test on `feature/**` and `fix/**` (fast-fail before
a change reaches `main`).
- `dev-release.yml` triggers on `push: branches: ['main']`.
- `rc-release.yml` triggers on `push: tags: ['v*']` gated to *only* run for
`-rc.` tags.
- `release.yml` triggers on `push: tags: ['v*']` gated to skip any tag
containing `-rc.` — that's the signed stable release.
- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the
dev-track workflow above. See bread-ecosystem's `docs/release-channels.md`
for the full track (stable/beta/dev) policy.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't merge this repo with `bread-screenshots` (the crate in
`bread-ecosystem`). Different jobs: breadshot is the user-facing
grim/slurp/wl-copy orchestrator; `bread-screenshots` is the capture
harness used by `bread-capture` to screenshot sibling apps in CI. They
share a grim backend and nothing else.

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# Contributing
`breadshot` — Screenshot utility for the bread ecosystem.
Part of the bread ecosystem; this repo follows the same branch/release
workflow as every other ecosystem product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release
cargo test --release
```
## CI
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

20
Cargo.lock generated
View file

@ -83,18 +83,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bread-utils"
version = "0.3.0"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
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.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"bread-shared",
"dirs",
"serde",
"serde_json",
]
[[package]]
name = "breadshot"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"anyhow",
"bread-utils",

View file

@ -1,6 +1,6 @@
[package]
name = "breadshot"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
license = "MIT"
authors = ["Breadway"]
@ -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.3.0" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
[profile.release]
lto = "thin"

96
EVENTS.md Normal file
View file

@ -0,0 +1,96 @@
# breadshot — bread event integration
breadshot is a standalone Wayland screenshot orchestrator: it works
exactly the same with or without `breadd` running. When breadd *is*
present, a successful capture publishes 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`). One-shot CLI invocations (`breadshot region`,
…) each `emit` on their own fire-and-forget connection (the same stance
`bread-emit` takes for occasional callers). Command verbs are only
received while `breadshot listen` is running — that process holds the
`bread.command.shot.**` 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), 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": "<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 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) 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
screenshot.
## Commands honored (`bread.command.shot.*`)
These are only received while `breadshot listen` is running. Publishing a
command with no subscriber is a silent no-op — that is the documented
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
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. 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
- If breadd isn't installed or isn't running, `emit` is a silent no-op
(`BreadClient::emit` never blocks or errors the caller) and the
command subscription simply never receives anything — breadshot's
actual grim/slurp/wl-copy path is entirely unaffected either way.
- If breadd restarts, the command subscription reconnects automatically
(`BreadClient::subscribe`'s background thread has its own backoff
loop); no restart of `breadshot listen` is needed.
- If `breadshot listen` is not running, commands are a graceful no-op at
the bus (no subscriber). The CLI still works, and one-shot invocations
still emit `bread.shot.captured` on their own short-lived connection.

View file

@ -1,6 +1,20 @@
# breadshot
Wayland screenshot utility for the bread ecosystem. Wraps `grim`, `slurp`, and `wl-copy` with Hyprland-aware geometry resolution, clipboard integration, and desktop notifications.
Wayland screenshot **orchestrator** for the bread ecosystem — not a GUI editor.
It shells out to `grim`, `slurp`, and `wl-copy` with Hyprland-aware geometry
resolution, clipboard integration, and desktop notifications.
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.
On BOS, Hyprland binds `Super+Shift+S` / `C` / `P` to breadshot (not grimblast):
| Bind | Action | Command |
|------|--------|---------|
| `Super+Shift+S` | Region → file (and clipboard) | `breadshot region` |
| `Super+Shift+C` | Region → clipboard only | `breadshot region --clipboard-only` |
| `Super+Shift+P` | Screen → file | `breadshot active-output` |
## Requirements
@ -14,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
@ -33,8 +49,16 @@ make install PREFIX=/usr
```
breadshot <mode> [options]
breadshot annotate [options]
breadshot listen
```
`breadshot listen` is the long-running process that honors
`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
| Mode | Description |
@ -44,6 +68,7 @@ breadshot <mode> [options]
| `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
@ -52,10 +77,15 @@ breadshot <mode> [options]
| `--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 <DIR>` | `-o` | Override the save directory from config |
| `--filename <NAME>` | `-f` | Override the output filename (without path) |
| `--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
```sh
@ -67,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
@ -85,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

View file

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

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
620c5a1317a6b57276eabca961facdb78bf510db

20
ci/build.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift.
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadshot "$ROOT" "$@"

View file

@ -1,5 +1,4 @@
use anyhow::{bail, Context, Result};
use clap::ValueEnum;
use serde_json::Value;
use std::{
io::Write,
@ -10,7 +9,11 @@ use std::{
use crate::config::Config;
#[derive(Debug, Clone, ValueEnum)]
/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as
/// `bread.shot.*`. See `EVENTS.md`.
pub(crate) const APP_ID: &str = "shot";
#[derive(Debug, Clone)]
pub enum Mode {
/// Select a region interactively
Region,
@ -19,22 +22,57 @@ pub enum Mode {
/// Click to select a monitor
Output,
/// Capture the active window
#[value(name = "active-window")]
ActiveWindow,
/// Capture the active monitor
#[value(name = "active-output")]
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,
pub freeze: bool,
pub annotate: bool,
pub output_dir: Option<PathBuf>,
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()?;
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
@ -49,27 +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,
)?;
}
if !silent {
// Both capture paths copy the PNG to the clipboard. `path` is null
// 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())
};
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 {
@ -78,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 ---
@ -237,6 +312,103 @@ fn trim_geometry(geometry: &str) -> Result<String> {
// --- 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<()> {
let mut grim = Command::new("grim")
.args(["-g", geometry, "-"])
@ -289,6 +461,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"]);
@ -304,8 +496,12 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
fn hyprctl_json(subcmd: &str) -> Result<Value> {
// Was a bare Command::new("hyprctl").output() with no timeout.
bread_utils::proc::run_json("hyprctl", &["-j", subcmd], std::time::Duration::from_secs(3))
.with_context(|| format!("running/parsing hyprctl {subcmd}"))
bread_utils::proc::run_json(
"hyprctl",
&["-j", subcmd],
std::time::Duration::from_secs(3),
)
.with_context(|| format!("running/parsing hyprctl {subcmd}"))
}
fn slurp(args: &[&str]) -> Result<String> {
@ -384,3 +580,87 @@ 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");
}
#[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");
}
}

View file

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

170
src/listen.rs Normal file
View file

@ -0,0 +1,170 @@
//! Long-running command subscription for `bread.command.shot.*`.
//!
//! `breadshot` is still a one-shot CLI by default. `breadshot listen` is the
//! optional persistent process that can honor bus commands. See `EVENTS.md`.
use anyhow::Result;
use bread_utils::bread_client::{BreadClient, BreadEvent};
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.
///
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
/// with backoff, and `on_event` simply isn't called until the daemon is up.
pub fn run(config: &Config) -> Result<()> {
let client = BreadClient::connect(APP_ID);
if client.health().is_none() {
tracing::warn!("breadd unreachable; command subscription will connect when it comes back");
}
let config = config.clone();
let _commands = client.subscribe("bread.command.shot.**", move |event| {
handle_command(&event, &config);
});
tracing::info!("listening for bread.command.shot.**");
loop {
std::thread::park();
}
}
/// 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.<verb>.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}'");
}
}
}
fn handle_region(config: &Config) {
// Clipboard-only matches the default region *bus* path: a Lua workflow
// that wants a file on disk can still `bread.exec("breadshot region")`.
let result = capture::run(
&Mode::Region,
config,
Overrides {
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()),
Err(e) => {
tracing::warn!("bread.command.shot.region failed: {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));
}
}
}
fn command_verb(event_name: &str) -> Option<&str> {
event_name.strip_prefix("bread.command.shot.")
}
fn region_done_payload() -> serde_json::Value {
serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null })
}
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::*;
#[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);
}
#[test]
fn region_done_payload_is_clipboard_only() {
let v = region_done_payload();
assert_eq!(v["clipboard"], true);
assert!(v["path"].is_null());
}
#[test]
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());
}
}

View file

@ -1,8 +1,9 @@
mod capture;
mod config;
mod listen;
use anyhow::Result;
use clap::Parser;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
@ -14,12 +15,19 @@ use config::Config;
name = "breadshot",
version,
about = "Screenshot utility for the bread ecosystem",
disable_help_subcommand = true,
disable_help_subcommand = true
)]
struct Cli {
/// Capture mode
mode: Mode,
#[command(subcommand)]
command: Command,
/// Path to config file
#[arg(long, value_name = "FILE", global = true)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct CaptureOpts {
/// Copy to clipboard only, don't save to disk
#[arg(long, short = 'c')]
clipboard_only: bool,
@ -32,6 +40,10 @@ struct Cli {
#[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<PathBuf>,
@ -39,10 +51,43 @@ struct Cli {
/// Override output filename (without path)
#[arg(long, short = 'f', value_name = "NAME")]
filename: Option<String>,
}
/// Path to config file
#[arg(long, value_name = "FILE")]
config: Option<PathBuf>,
#[derive(Subcommand)]
enum Command {
/// Select a region interactively
Region(CaptureOpts),
/// Click to select a window
Window(CaptureOpts),
/// Click to select a monitor
Output(CaptureOpts),
/// Capture the active window
#[command(name = "active-window")]
ActiveWindow(CaptureOpts),
/// Capture the active monitor
#[command(name = "active-output")]
ActiveOutput(CaptureOpts),
/// Capture a region, freeze the frame, and annotate
Annotate(CaptureOpts),
/// Subscribe to bread.command.shot.** and honor region/annotate captures
Listen,
}
impl Command {
fn into_capture(self) -> Option<(Mode, CaptureOpts)> {
match self {
Self::Region(opts) => Some((Mode::Region, opts)),
Self::Window(opts) => Some((Mode::Window, opts)),
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,
}
}
}
fn main() -> Result<()> {
@ -58,15 +103,21 @@ fn main() -> Result<()> {
None => Config::load()?,
};
let Some((mode, opts)) = cli.command.into_capture() else {
return listen::run(&config);
};
capture::run(
&cli.mode,
&mode,
&config,
Overrides {
clipboard_only: cli.clipboard_only,
silent: cli.silent,
freeze: cli.freeze,
output_dir: cli.output_dir,
filename: cli.filename,
clipboard_only: opts.clipboard_only,
silent: opts.silent,
freeze: opts.freeze,
annotate: opts.annotate,
output_dir: opts.output_dir,
filename: opts.filename,
},
)
)?;
Ok(())
}