From 876fbc4a2330699e5d418f1cca6aa6a32f6d41ec Mon Sep 17 00:00:00 2001 From: x71c9 <108585118+x71c9@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:51:05 +0200 Subject: [PATCH] feat: screenshot with border. wayland and mac support --- Cargo.lock | 70 +++++++++++++++++++++++ Cargo.toml | 6 +- Cross.toml | 4 +- ci.toml | 6 +- shell.nix | 1 + src/backend/macos.rs | 30 ++++++++++ src/backend/mod.rs | 47 ++++++++++++++++ src/backend/wayland.rs | 52 +++++++++++++++++ src/{capture.rs => backend/x11.rs} | 90 ++++++++++++++++++++++++++---- src/clipboard.rs | 19 ++++++- src/main.rs | 18 ++---- src/screenshot.rs | 41 -------------- 12 files changed, 306 insertions(+), 78 deletions(-) create mode 100644 src/backend/macos.rs create mode 100644 src/backend/mod.rs create mode 100644 src/backend/wayland.rs rename src/{capture.rs => backend/x11.rs} (73%) delete mode 100644 src/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index bb41a9d..f32cf84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -44,6 +50,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -60,6 +81,25 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -125,6 +165,7 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", + "png", ] [[package]] @@ -148,6 +189,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -167,6 +218,19 @@ dependencies = [ "autocfg", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -225,6 +289,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "syn" version = "2.0.118" diff --git a/Cargo.toml b/Cargo.toml index bcd6043..6bcd875 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,10 +13,12 @@ path = "src/main.rs" [dependencies] anyhow = "1" -x11rb = { version = "0.13", features = ["allow-unsafe-code"] } -image = { version = "0.25", default-features = false } +image = { version = "0.25", default-features = false, features = ["png"] } rqrr = "0.8" +[target.'cfg(not(target_os = "macos"))'.dependencies] +x11rb = { version = "0.13", features = ["allow-unsafe-code"] } + [package.metadata.release] push = true publish = true diff --git a/Cross.toml b/Cross.toml index 008dc14..25b160e 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,8 +1,8 @@ [target.x86_64-unknown-linux-gnu] -pre-build = ["apt-get update && apt-get install -y libxcb-dev"] +pre-build = ["apt-get update && apt-get install -y libxcb1-dev"] [target.aarch64-unknown-linux-gnu] pre-build = [ "dpkg --add-architecture arm64", - "apt-get update && apt-get install -y libxcb-dev:arm64" + "apt-get update && apt-get install -y libxcb1-dev:arm64" ] diff --git a/ci.toml b/ci.toml index 7fa16c4..1eacac2 100644 --- a/ci.toml +++ b/ci.toml @@ -8,11 +8,7 @@ pkgdesc = "CLI tool to capture a screen region, decode any QR code found, an # nur_build_inputs = "openssl" # Extra apt packages needed to compile on Ubuntu (CI build machines) -extra_apt_packages = "libxcb1-dev" - -# macOS: extra Homebrew packages and env vars needed to compile -# macos_extra_brew = "--cask xquartz" -# macos_extra_env = "LIBRARY_PATH=/opt/X11/lib PKG_CONFIG_PATH=/opt/X11/lib/pkgconfig" +extra_apt_packages = "libxcb1-dev libwayland-dev" # Shell completions: set to true if the binary exposes a `completion ` subcommand # has_shell_completions = "true" diff --git a/shell.nix b/shell.nix index f13fae0..834a80c 100644 --- a/shell.nix +++ b/shell.nix @@ -9,6 +9,7 @@ pkgs.mkShell { pkgs.pkg-config pkgs.xorg.libxcb pkgs.xorg.libX11 + pkgs.wayland ]; shellHook = '' export PATH="$PWD/scripts:$PATH" diff --git a/src/backend/macos.rs b/src/backend/macos.rs new file mode 100644 index 0000000..4256549 --- /dev/null +++ b/src/backend/macos.rs @@ -0,0 +1,30 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use image::GrayImage; + +pub struct MacOsBackend; + +impl super::Backend for MacOsBackend { + fn capture(&self) -> anyhow::Result> { + let mut child = Command::new("screencapture") + .args(["-i", "-s", "-"]) + .stdout(Stdio::piped()) + .spawn()?; + + let mut png = Vec::new(); + child + .stdout + .as_mut() + .expect("stdout piped") + .read_to_end(&mut png)?; + + let status = child.wait()?; + if !status.success() || png.is_empty() { + return Ok(None); + } + + let img = image::load_from_memory(&png)?.into_luma8(); + Ok(Some(img)) + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..f1abd15 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,47 @@ +pub mod x11; + +#[cfg(not(target_os = "macos"))] +pub mod wayland; + +#[cfg(target_os = "macos")] +pub mod macos; + +use image::GrayImage; + +pub trait Backend { + fn capture(&self) -> anyhow::Result>; +} + +pub enum DisplayServer { + #[cfg(not(target_os = "macos"))] + Wayland, + X11, + #[cfg(target_os = "macos")] + MacOs, +} + +pub fn detect() -> anyhow::Result { + #[cfg(target_os = "macos")] + return Ok(DisplayServer::MacOs); + + #[cfg(not(target_os = "macos"))] + if std::env::var("WAYLAND_DISPLAY").is_ok() { + return Ok(DisplayServer::Wayland); + } + + if std::env::var("DISPLAY").is_ok() { + return Ok(DisplayServer::X11); + } + + anyhow::bail!("no display found: neither WAYLAND_DISPLAY nor DISPLAY is set") +} + +pub fn build() -> anyhow::Result> { + match detect()? { + #[cfg(not(target_os = "macos"))] + DisplayServer::Wayland => Ok(Box::new(wayland::WaylandBackend)), + DisplayServer::X11 => Ok(Box::new(x11::X11Backend::new()?)), + #[cfg(target_os = "macos")] + DisplayServer::MacOs => Ok(Box::new(macos::MacOsBackend)), + } +} diff --git a/src/backend/wayland.rs b/src/backend/wayland.rs new file mode 100644 index 0000000..40d86cd --- /dev/null +++ b/src/backend/wayland.rs @@ -0,0 +1,52 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use image::GrayImage; + +pub struct WaylandBackend; + +impl super::Backend for WaylandBackend { + fn capture(&self) -> anyhow::Result> { + let geom = slurp()?; + let geom = match geom { + Some(g) => g, + None => return Ok(None), + }; + + let png = grim(&geom)?; + let img = image::load_from_memory(&png)?.into_luma8(); + Ok(Some(img)) + } +} + +fn slurp() -> anyhow::Result> { + let output = Command::new("slurp").output()?; + if !output.status.success() { + return Ok(None); + } + let geom = String::from_utf8(output.stdout)?.trim().to_string(); + if geom.is_empty() { + return Ok(None); + } + Ok(Some(geom)) +} + +fn grim(geom: &str) -> anyhow::Result> { + let mut child = Command::new("grim") + .args(["-g", geom, "-"]) + .stdout(Stdio::piped()) + .spawn()?; + + let mut png = Vec::new(); + child + .stdout + .as_mut() + .expect("stdout piped") + .read_to_end(&mut png)?; + + let status = child.wait()?; + if !status.success() { + anyhow::bail!("grim exited with status {status}"); + } + Ok(png) +} diff --git a/src/capture.rs b/src/backend/x11.rs similarity index 73% rename from src/capture.rs rename to src/backend/x11.rs index bc62b3c..cd62560 100644 --- a/src/capture.rs +++ b/src/backend/x11.rs @@ -1,5 +1,6 @@ use std::cmp::{max, min}; +use image::GrayImage; use x11rb::{ connection::Connection, protocol::{ @@ -13,14 +14,36 @@ use x11rb::{ rust_connection::RustConnection, }; -pub struct Region { - pub x: i16, - pub y: i16, - pub width: u16, - pub height: u16, +pub struct X11Backend { + conn: RustConnection, + screen_num: usize, +} + +impl X11Backend { + pub fn new() -> anyhow::Result { + let (conn, screen_num) = RustConnection::connect(None)?; + Ok(Self { conn, screen_num }) + } +} + +impl super::Backend for X11Backend { + fn capture(&self) -> anyhow::Result> { + let region = match select_region(&self.conn, self.screen_num)? { + Some(r) => r, + None => return Ok(None), + }; + Ok(Some(screenshot(&self.conn, self.screen_num, ®ion)?)) + } } -pub fn select_region( +struct Region { + x: i16, + y: i16, + width: u16, + height: u16, +} + +fn select_region( conn: &RustConnection, screen_num: usize, ) -> anyhow::Result> { @@ -31,7 +54,6 @@ pub fn select_region( let depth = screen.root_depth; let visual = screen.root_visual; - // Snapshot the screen before opening the overlay so the user can see it let bg_pixmap = snapshot_to_pixmap(conn, root, sw, sh, depth)?; let win: Window = conn.generate_id()?; @@ -86,7 +108,6 @@ pub fn select_region( )?; conn.flush()?; - // XOR GC for rubber-band rectangle: drawing twice erases let gc = conn.generate_id()?; conn.create_gc( gc, @@ -98,6 +119,15 @@ pub fn select_region( .line_width(2), )?; + let border_gc = conn.generate_id()?; + conn.create_gc( + border_gc, + win, + &CreateGCAux::new().foreground(0x00_AA_00_00).line_width(1), + )?; + draw_rect(conn, win, border_gc, 0, 0, sw - 1, sh - 1)?; + conn.flush()?; + let mut start: Option<(i16, i16)> = None; let mut last_rect: Option<(i16, i16, u16, u16)> = None; let mut result: Option = None; @@ -139,6 +169,7 @@ pub fn select_region( } } Event::Expose(_) => { + draw_rect(conn, win, border_gc, 0, 0, sw - 1, sh - 1)?; if let Some((rx, ry, rw, rh)) = last_rect { draw_rect(conn, win, gc, rx, ry, rw, rh)?; } @@ -167,12 +198,11 @@ fn snapshot_to_pixmap( .get_image(ImageFormat::Z_PIXMAP, root, 0, 0, sw, sh, !0)? .reply()?; - // Darken each pixel by 10% so the overlay reads as slightly dimmed let mut data = img.data; for chunk in data.chunks_mut(4) { - chunk[0] = (chunk[0] as f32 * 0.8) as u8; // B - chunk[1] = (chunk[1] as f32 * 0.8) as u8; // G - chunk[2] = (chunk[2] as f32 * 0.8) as u8; // R + chunk[0] = (chunk[0] as f32 * 0.85) as u8; + chunk[1] = (chunk[1] as f32 * 0.85) as u8; + chunk[2] = (chunk[2] as f32 * 0.85) as u8; } let pixmap: Pixmap = conn.generate_id()?; @@ -198,6 +228,42 @@ fn snapshot_to_pixmap( Ok(pixmap) } +fn screenshot( + conn: &RustConnection, + screen_num: usize, + region: &Region, +) -> anyhow::Result { + let root = conn.setup().roots[screen_num].root; + + let reply = conn + .get_image( + ImageFormat::Z_PIXMAP, + root, + region.x, + region.y, + region.width, + region.height, + !0, + )? + .reply()?; + + let data = reply.data; + let w = region.width as u32; + let h = region.height as u32; + let mut gray = GrayImage::new(w, h); + + for (i, pixel) in gray.pixels_mut().enumerate() { + let base = i * 4; + let b = data[base] as u32; + let g = data[base + 1] as u32; + let r = data[base + 2] as u32; + let luma = (r * 299 + g * 587 + b * 114) / 1000; + *pixel = image::Luma([luma as u8]); + } + + Ok(gray) +} + fn rect_from_points( x1: i16, y1: i16, diff --git a/src/clipboard.rs b/src/clipboard.rs index 70be93d..eccd3eb 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -2,8 +2,21 @@ use std::io::Write; use std::process::{Command, Stdio}; pub fn copy_to_clipboard(text: &str) -> anyhow::Result<()> { - let mut child = Command::new("xclip") - .args(["-selection", "clipboard"]) + #[cfg(target_os = "macos")] + let args: &[&str] = &[]; + #[cfg(target_os = "macos")] + let program = "pbcopy"; + + #[cfg(not(target_os = "macos"))] + let (program, args): (&str, &[&str]) = + if std::env::var("WAYLAND_DISPLAY").is_ok() { + ("wl-copy", &[]) + } else { + ("xclip", &["-selection", "clipboard"]) + }; + + let mut child = Command::new(program) + .args(args) .stdin(Stdio::piped()) .spawn()?; @@ -15,7 +28,7 @@ pub fn copy_to_clipboard(text: &str) -> anyhow::Result<()> { let status = child.wait()?; if !status.success() { - anyhow::bail!("xclip exited with status {status}"); + anyhow::bail!("{program} exited with status {status}"); } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 6272422..c23a900 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,6 @@ -mod capture; +mod backend; mod clipboard; mod decode; -mod screenshot; - -use x11rb::rust_connection::RustConnection; fn main() { if let Err(e) = run() { @@ -13,19 +10,14 @@ fn main() { } fn run() -> anyhow::Result<()> { - let (conn, screen_num) = RustConnection::connect(None)?; + let backend = backend::build()?; - let region = match capture::select_region(&conn, screen_num)? { - Some(r) => r, - None => { - eprintln!("selection cancelled"); - return Ok(()); - } + let img = match backend.capture()? { + Some(img) => img, + None => std::process::exit(1), }; - let img = screenshot::capture(&conn, screen_num, ®ion)?; let text = decode::decode_qr(img)?; - clipboard::copy_to_clipboard(&text)?; println!("{text}"); diff --git a/src/screenshot.rs b/src/screenshot.rs deleted file mode 100644 index fad578a..0000000 --- a/src/screenshot.rs +++ /dev/null @@ -1,41 +0,0 @@ -use image::GrayImage; -use x11rb::{connection::Connection, protocol::xproto::ConnectionExt}; - -use crate::capture::Region; - -pub fn capture( - conn: &x11rb::rust_connection::RustConnection, - screen_num: usize, - region: &Region, -) -> anyhow::Result { - let root = conn.setup().roots[screen_num].root; - - let reply = conn.get_image( - x11rb::protocol::xproto::ImageFormat::Z_PIXMAP, - root, - region.x, - region.y, - region.width, - region.height, - !0, - )?; - let reply = reply.reply()?; - - // X11 ZPixmap is BGRA (or BGRx) 32-bit per pixel - let data = reply.data; - let w = region.width as u32; - let h = region.height as u32; - let mut gray = GrayImage::new(w, h); - - for (i, pixel) in gray.pixels_mut().enumerate() { - let base = i * 4; - let b = data[base] as u32; - let g = data[base + 1] as u32; - let r = data[base + 2] as u32; - // Rec.601 luma - let luma = (r * 299 + g * 587 + b * 114) / 1000; - *pixel = image::Luma([luma as u8]); - } - - Ok(gray) -}