Initial commit

This commit is contained in:
Breadway 2026-07-02 20:59:24 +08:00
commit 854c5752f9
10 changed files with 1614 additions and 0 deletions

389
src/capture.rs Normal file
View file

@ -0,0 +1,389 @@
use anyhow::{bail, Context, Result};
use clap::ValueEnum;
use serde_json::Value;
use std::{
io::Write,
path::{Path, PathBuf},
process::{Command, Stdio},
time::Duration,
};
use crate::config::Config;
#[derive(Debug, Clone, ValueEnum)]
pub enum Mode {
/// Select a region interactively
Region,
/// Click to select a window
Window,
/// Click to select a monitor
Output,
/// Capture the active window
#[value(name = "active-window")]
ActiveWindow,
/// Capture the active monitor
#[value(name = "active-output")]
ActiveOutput,
}
pub struct Overrides {
pub clipboard_only: bool,
pub silent: bool,
pub freeze: bool,
pub output_dir: Option<PathBuf>,
pub filename: Option<String>,
}
pub fn run(mode: &Mode, config: &Config, overrides: Overrides) -> Result<()> {
check_deps()?;
let save_dir = overrides.output_dir.as_ref().unwrap_or(&config.save_dir);
let filename = overrides.filename.unwrap_or_else(|| {
format!(
"{}_breadshot.png",
chrono::Local::now().format(&config.date_format)
)
});
let save_path = save_dir.join(&filename);
let silent = overrides.silent || config.silent;
let freeze = overrides.freeze || config.freeze;
let clipboard_only = overrides.clipboard_only;
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 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 !silent {
let msg = if clipboard_only {
"Copied to clipboard".to_string()
} else {
format!("Saved to <i>{}</i>", save_path.display())
};
send_notification("Screenshot", &msg, config.notif_timeout, &save_path);
}
Ok(())
}
// --- geometry ---
fn geometry_for_mode(mode: &Mode) -> Result<String> {
match mode {
Mode::Region => geometry_region(),
Mode::Window => geometry_window().and_then(|g| trim_geometry(&g)),
Mode::Output => geometry_output(),
Mode::ActiveWindow => geometry_active_window().and_then(|g| trim_geometry(&g)),
Mode::ActiveOutput => geometry_active_output(),
}
}
fn geometry_region() -> Result<String> {
slurp(&["-d"])
}
fn geometry_output() -> Result<String> {
slurp(&["-or"])
}
fn geometry_window() -> Result<String> {
let monitors = hyprctl_json("monitors")?;
let active_ids: Vec<i64> = monitors
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|m| m["activeWorkspace"]["id"].as_i64())
.collect();
let clients = hyprctl_json("clients")?;
let boxes: Vec<String> = clients
.as_array()
.unwrap_or(&vec![])
.iter()
.filter(|c| {
c["workspace"]["id"]
.as_i64()
.is_some_and(|id| active_ids.contains(&id))
})
.map(|c| {
format!(
"{},{} {}x{}",
c["at"][0].as_i64().unwrap_or(0),
c["at"][1].as_i64().unwrap_or(0),
c["size"][0].as_i64().unwrap_or(0),
c["size"][1].as_i64().unwrap_or(0),
)
})
.collect();
slurp_piped(&["-r"], &boxes.join("\n"))
}
fn geometry_active_window() -> Result<String> {
let w = hyprctl_json("activewindow")?;
Ok(format!(
"{},{} {}x{}",
w["at"][0].as_i64().context("missing at[0]")?,
w["at"][1].as_i64().context("missing at[1]")?,
w["size"][0].as_i64().context("missing size[0]")?,
w["size"][1].as_i64().context("missing size[1]")?,
))
}
fn geometry_active_output() -> Result<String> {
let active_id = hyprctl_json("activeworkspace")?["id"]
.as_i64()
.context("no active workspace id")?;
let monitors = hyprctl_json("monitors")?;
let m = monitors
.as_array()
.and_then(|arr| {
arr.iter()
.find(|m| m["activeWorkspace"]["id"].as_i64() == Some(active_id))
})
.context("no monitor for active workspace")?;
monitor_geometry(m)
}
fn monitor_geometry(m: &Value) -> Result<String> {
let x = m["x"].as_i64().unwrap_or(0);
let y = m["y"].as_i64().unwrap_or(0);
let w = m["width"].as_f64().context("missing width")?;
let h = m["height"].as_f64().context("missing height")?;
let scale = m["scale"].as_f64().unwrap_or(1.0);
Ok(format!(
"{x},{y} {}x{}",
(w / scale).round() as i64,
(h / scale).round() as i64
))
}
// Clips window geometry to the logical bounds of the monitor layout.
// hyprctl clients returns logical (scaled) coordinates, but a window
// can technically extend outside the visible area.
fn trim_geometry(geometry: &str) -> Result<String> {
let (xy, wh) = geometry.split_once(' ').context("invalid geometry")?;
let (xs, ys) = xy.split_once(',').context("invalid xy")?;
let (ws, hs) = wh.split_once('x').context("invalid wh")?;
let x: i64 = xs.trim().parse().context("invalid x")?;
let y: i64 = ys.trim().parse().context("invalid y")?;
let w: i64 = ws.trim().parse().context("invalid w")?;
let h: i64 = hs.trim().parse().context("invalid h")?;
let monitors = hyprctl_json("monitors")?;
let monitors = monitors.as_array().context("monitors not an array")?;
let (max_x, max_y, min_x, min_y) =
monitors
.iter()
.fold((i64::MIN, i64::MIN, i64::MAX, i64::MAX), |acc, m| {
let mx = m["x"].as_i64().unwrap_or(0);
let my = m["y"].as_i64().unwrap_or(0);
let mw = m["width"].as_f64().unwrap_or(0.0);
let mh = m["height"].as_f64().unwrap_or(0.0);
let scale = m["scale"].as_f64().unwrap_or(1.0);
let transform = m["transform"].as_i64().unwrap_or(0);
let (lw, lh) = if transform % 2 == 0 {
((mw / scale).round() as i64, (mh / scale).round() as i64)
} else {
((mh / scale).round() as i64, (mw / scale).round() as i64)
};
(
acc.0.max(mx + lw),
acc.1.max(my + lh),
acc.2.min(mx),
acc.3.min(my),
)
});
let mut cx = x;
let mut cy = y;
let mut cw = w;
let mut ch = h;
if x + w > max_x {
cw = max_x - x;
}
if y + h > max_y {
ch = max_y - y;
}
if x < min_x {
cx = min_x;
cw += x - min_x;
}
if y < min_y {
cy = min_y;
ch += y - min_y;
}
Ok(format!("{cx},{cy} {cw}x{ch}"))
}
// --- capture ---
fn copy_only(geometry: &str) -> Result<()> {
let mut grim = Command::new("grim")
.args(["-g", geometry, "-"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("spawning grim")?;
let stdout = grim.stdout.take().context("grim stdout")?;
let mut wl_copy = Command::new("wl-copy")
.args(["--type", "image/png"])
.stdin(stdout)
.spawn()
.context("spawning wl-copy")?;
grim.wait().context("waiting for grim")?;
wl_copy.wait().context("waiting for wl-copy")?;
Ok(())
}
fn save_and_copy(geometry: &str, path: &Path) -> Result<()> {
let status = Command::new("grim")
.args(["-g", geometry, path.to_str().unwrap_or("")])
.status()
.context("running grim")?;
if !status.success() {
bail!("grim exited with {status}");
}
let data = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let mut wl_copy = Command::new("wl-copy")
.args(["--type", "image/png"])
.stdin(Stdio::piped())
.spawn()
.context("spawning wl-copy")?;
wl_copy
.stdin
.take()
.context("wl-copy stdin")?
.write_all(&data)
.context("piping to wl-copy")?;
wl_copy.wait().context("waiting for wl-copy")?;
Ok(())
}
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"]);
if path.exists() {
cmd.args(["-i", path.to_str().unwrap_or("")]);
}
if let Err(e) = cmd.status() {
tracing::warn!("notify-send: {e}");
}
}
// --- helpers ---
fn hyprctl_json(subcmd: &str) -> Result<Value> {
let out = Command::new("hyprctl")
.args(["-j", subcmd])
.output()
.context("running hyprctl")?;
serde_json::from_slice(&out.stdout)
.with_context(|| format!("parsing hyprctl {subcmd} output"))
}
fn slurp(args: &[&str]) -> Result<String> {
let out = Command::new("slurp")
.args(args)
.output()
.context("running slurp")?;
if !out.status.success() {
bail!("selection cancelled");
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn slurp_piped(args: &[&str], stdin_data: &str) -> Result<String> {
let mut child = Command::new("slurp")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context("spawning slurp")?;
child
.stdin
.take()
.context("slurp stdin")?
.write_all(stdin_data.as_bytes())
.context("writing to slurp")?;
let out = child.wait_with_output().context("waiting for slurp")?;
if !out.status.success() {
bail!("selection cancelled");
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn in_path(cmd: &str) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(cmd).exists()))
.unwrap_or(false)
}
fn check_deps() -> Result<()> {
let required = ["grim", "slurp", "wl-copy", "hyprctl"];
let missing: Vec<_> = required.iter().copied().filter(|&d| !in_path(d)).collect();
if !missing.is_empty() {
bail!("missing required tools: {}", missing.join(", "));
}
Ok(())
}
// --- freeze ---
struct FreezeGuard {
child: std::process::Child,
}
impl FreezeGuard {
fn try_spawn() -> Result<Self> {
if !in_path("hyprpicker") {
bail!("hyprpicker not installed");
}
let child = Command::new("hyprpicker")
.args(["-r", "-z"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("spawning hyprpicker")?;
std::thread::sleep(Duration::from_millis(200));
Ok(Self { child })
}
}
impl Drop for FreezeGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

51
src/config.rs Normal file
View file

@ -0,0 +1,51 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub save_dir: PathBuf,
pub silent: bool,
pub freeze: bool,
pub notif_timeout: u32,
pub date_format: String,
}
impl Default for Config {
fn default() -> Self {
Self {
save_dir: dirs::picture_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
.join("Screenshots"),
silent: false,
freeze: false,
notif_timeout: 5000,
date_format: "%Y-%m-%d-%H%M%S".to_string(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
Self::load_from(&default_path())
}
pub fn load_from(path: &Path) -> Result<Self> {
if !path.exists() {
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()))?;
toml::from_str(&content)
.with_context(|| format!("parsing {}", path.display()))
}
}
pub fn default_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
.join("breadshot")
.join("config.toml")
}

72
src/main.rs Normal file
View file

@ -0,0 +1,72 @@
mod capture;
mod config;
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
use capture::{Mode, Overrides};
use config::Config;
#[derive(Parser)]
#[command(
name = "breadshot",
version,
about = "Screenshot utility for the bread ecosystem",
disable_help_subcommand = true,
)]
struct Cli {
/// Capture mode
mode: Mode,
/// Copy to clipboard only, don't save to disk
#[arg(long, short = 'c')]
clipboard_only: bool,
/// Suppress notifications
#[arg(long, short = 's')]
silent: bool,
/// Freeze screen during selection (requires hyprpicker)
#[arg(long, short = 'z')]
freeze: bool,
/// Override save directory from config
#[arg(long, short = 'o', value_name = "DIR")]
output_dir: Option<PathBuf>,
/// 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>,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.without_time()
.init();
let cli = Cli::parse();
let config = match &cli.config {
Some(path) => Config::load_from(path)?,
None => Config::load()?,
};
capture::run(
&cli.mode,
&config,
Overrides {
clipboard_only: cli.clipboard_only,
silent: cli.silent,
freeze: cli.freeze,
output_dir: cli.output_dir,
filename: cli.filename,
},
)
}