Honor bread.command.shot.region via breadshot listen
Some checks failed
dev release / build (push) Failing after 0s
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.
This commit is contained in:
parent
c68214a6ab
commit
365072d65e
7 changed files with 227 additions and 52 deletions
|
|
@ -1,5 +1,4 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::ValueEnum;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Write,
|
||||
|
|
@ -12,9 +11,9 @@ 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";
|
||||
pub(crate) const APP_ID: &str = "shot";
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Mode {
|
||||
/// Select a region interactively
|
||||
Region,
|
||||
|
|
@ -23,10 +22,8 @@ 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,
|
||||
}
|
||||
|
||||
|
|
@ -353,8 +350,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> {
|
||||
|
|
|
|||
110
src/listen.rs
Normal file
110
src/listen.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! 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, 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` is honored today —
|
||||
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||
///
|
||||
/// Emits `bread.shot.region.done` / `.failed` per the confirmation convention
|
||||
/// in bread's Documentation.md.
|
||||
fn handle_command(event: &BreadEvent, config: &Config) {
|
||||
let Some(verb) = command_verb(&event.event) else {
|
||||
return;
|
||||
};
|
||||
match verb {
|
||||
"region" => handle_region(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,
|
||||
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", region_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 region_failed_payload(error: &impl ToString) -> serde_json::Value {
|
||||
serde_json::json!({ "error": error.to_string() })
|
||||
}
|
||||
|
||||
#[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.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 region_failed_payload_includes_error() {
|
||||
let v = region_failed_payload(&"selection cancelled");
|
||||
assert_eq!(v["error"], "selection cancelled");
|
||||
}
|
||||
}
|
||||
65
src/main.rs
65
src/main.rs
|
|
@ -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,
|
||||
|
|
@ -39,10 +47,37 @@ 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),
|
||||
/// Subscribe to bread.command.shot.** and honor region 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::Listen => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
|
|
@ -58,15 +93,19 @@ 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,
|
||||
output_dir: opts.output_dir,
|
||||
filename: opts.filename,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue