Compare commits
3 commits
dad2a45d83
...
c68214a6ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68214a6ab | ||
|
|
5d1b8918d3 | ||
|
|
84539f2fcd |
7 changed files with 220 additions and 6 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -31,4 +31,3 @@ logs/
|
|||
*.pid
|
||||
|
||||
# Local hygiene notes (not for commit)
|
||||
CLAUDE.md
|
||||
|
|
|
|||
43
AGENTS.md
Normal file
43
AGENTS.md
Normal 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.
|
||||
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -83,13 +83,25 @@ 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.3.1"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
||||
dependencies = [
|
||||
"bread-shared",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.1", features = ["bread-client"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
66
EVENTS.md
Normal file
66
EVENTS.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# breadshot — bread event integration
|
||||
|
||||
breadshot is a standalone, one-shot Wayland screenshot orchestrator: it
|
||||
works exactly the same with or without `breadd` running. When breadd *is*
|
||||
present, a successful capture publishes one event into the shared bread
|
||||
automation fabric. See the parent `bread` repo's `Documentation.md` —
|
||||
specifically its "Namespaces" and "Integrating a bread\* app" sections —
|
||||
for the general convention this follows.
|
||||
|
||||
This is a different job from `bread-screenshots` (the crate in
|
||||
`bread-ecosystem`): that one is a capture harness for screenshotting
|
||||
sibling apps in CI. Do not merge the two.
|
||||
|
||||
App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module
|
||||
(feature `bread-client`). breadshot is a short-lived CLI, not a daemon —
|
||||
each `emit` is its own fire-and-forget connection (the same stance
|
||||
`bread-emit` takes for occasional callers). There is no process to hold a
|
||||
command subscription open.
|
||||
|
||||
## Events published (`bread.shot.*`)
|
||||
|
||||
| Event | Data | When |
|
||||
|-------|------|------|
|
||||
| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": <string or null> }` | A capture completed successfully (grim + clipboard write both returned). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. |
|
||||
|
||||
`mode` is the CLI mode name (same strings `breadshot <mode>` accepts).
|
||||
`clipboard` is whether the PNG was written to the clipboard — both current
|
||||
capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is
|
||||
the saved file, or `null` when `--clipboard-only` was used (no file on
|
||||
disk).
|
||||
|
||||
The image bytes themselves are never included in the payload. The event
|
||||
bus is a notification that a capture happened, not a channel for the
|
||||
screenshot.
|
||||
|
||||
## Commands honored (`bread.command.shot.*`)
|
||||
|
||||
None. breadshot is a one-shot CLI with no persistent process to subscribe
|
||||
to `bread.command.shot.*`. A Lua workflow that wants a screenshot should
|
||||
shell out:
|
||||
|
||||
```lua
|
||||
bread.exec("breadshot region")
|
||||
-- or
|
||||
bread.exec("breadshot region --clipboard-only")
|
||||
bread.exec("breadshot active-output")
|
||||
```
|
||||
|
||||
The outcome of that exec is the same `bread.shot.captured` event the
|
||||
keybind path already publishes — `bread.wait("bread.shot.captured")`
|
||||
inside a spawned coroutine if the workflow needs to react to the file.
|
||||
|
||||
There is no `pin`, `select`, `edit`, or other command verb. breadshot has
|
||||
no editor, no history, and no concept those verbs could hang on.
|
||||
`bread.exec("breadshot …")` is the whole command surface. If/when a
|
||||
long-running piece exists, verbs should be added then, not stubbed as
|
||||
no-ops ahead of it.
|
||||
|
||||
## Fail-safe behavior
|
||||
|
||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||
(`BreadClient::emit` never blocks or errors the caller) — breadshot's
|
||||
actual grim/slurp/wl-copy path is entirely unaffected either way.
|
||||
- There is no command subscription, so a breadd restart cannot drop one.
|
||||
The next `breadshot` invocation emits (or silently doesn't) on its own
|
||||
short-lived connection.
|
||||
16
README.md
16
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ use std::{
|
|||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as
|
||||
/// `bread.shot.*`. See `EVENTS.md`.
|
||||
const APP_ID: &str = "shot";
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
pub enum Mode {
|
||||
/// Select a region interactively
|
||||
|
|
@ -26,6 +30,19 @@ pub enum Mode {
|
|||
ActiveOutput,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// CLI / event-payload name (`region`, `active-window`, …).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Region => "region",
|
||||
Self::Window => "window",
|
||||
Self::Output => "output",
|
||||
Self::ActiveWindow => "active-window",
|
||||
Self::ActiveOutput => "active-output",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Overrides {
|
||||
pub clipboard_only: bool,
|
||||
pub silent: bool,
|
||||
|
|
@ -69,6 +86,18 @@ pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
|
|||
save_and_copy(&geometry, &save_path)?;
|
||||
}
|
||||
|
||||
// Both capture paths copy the PNG to the clipboard. `path` is null
|
||||
// when the user asked for clipboard-only (no file on disk).
|
||||
emit_captured(
|
||||
mode,
|
||||
true,
|
||||
if clipboard_only {
|
||||
None
|
||||
} else {
|
||||
Some(save_path.as_path())
|
||||
},
|
||||
);
|
||||
|
||||
if !silent {
|
||||
let msg = if clipboard_only {
|
||||
"Copied to clipboard".to_string()
|
||||
|
|
@ -289,6 +318,26 @@ fn save_and_copy(geometry: &str, path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes `bread.shot.captured` into the bread event fabric. Fire-and-forget
|
||||
/// and non-fatal by design (`BreadClient::emit` never blocks or errors this
|
||||
/// caller) — breadd being absent or not installed must never affect
|
||||
/// breadshot's own capture path, only mean this one notification doesn't
|
||||
/// go anywhere.
|
||||
fn emit_captured(mode: &Mode, clipboard: bool, path: Option<&Path>) {
|
||||
bread_utils::bread_client::BreadClient::connect(APP_ID).emit(
|
||||
"bread.shot.captured",
|
||||
captured_payload(mode, clipboard, path),
|
||||
);
|
||||
}
|
||||
|
||||
fn captured_payload(mode: &Mode, clipboard: bool, path: Option<&Path>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"mode": mode.as_str(),
|
||||
"clipboard": clipboard,
|
||||
"path": path.map(|p| p.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) {
|
||||
let mut cmd = Command::new("notify-send");
|
||||
cmd.args([title, msg, "-t", &timeout.to_string(), "-a", "breadshot"]);
|
||||
|
|
@ -384,3 +433,34 @@ impl Drop for FreezeGuard {
|
|||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mode_as_str_matches_cli_names() {
|
||||
assert_eq!(Mode::Region.as_str(), "region");
|
||||
assert_eq!(Mode::Window.as_str(), "window");
|
||||
assert_eq!(Mode::Output.as_str(), "output");
|
||||
assert_eq!(Mode::ActiveWindow.as_str(), "active-window");
|
||||
assert_eq!(Mode::ActiveOutput.as_str(), "active-output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_payload_clipboard_only_has_null_path() {
|
||||
let v = captured_payload(&Mode::Region, true, None);
|
||||
assert_eq!(v["mode"], "region");
|
||||
assert_eq!(v["clipboard"], true);
|
||||
assert!(v["path"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_payload_saved_file_includes_path() {
|
||||
let path = Path::new("/tmp/shot.png");
|
||||
let v = captured_payload(&Mode::ActiveOutput, true, Some(path));
|
||||
assert_eq!(v["mode"], "active-output");
|
||||
assert_eq!(v["clipboard"], true);
|
||||
assert_eq!(v["path"], "/tmp/shot.png");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue