diff --git a/Cargo.lock b/Cargo.lock index 5abc856..51efaee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -468,6 +477,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bindgen" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4243e6031260db77ede97ad86c27e501d646a27ab57b59a574f725d98ab1fb4" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 1.0.109", + "which", +] + [[package]] name = "bit-set" version = "0.6.0" @@ -509,6 +540,7 @@ dependencies = [ "egui_extras", "gbm", "image", + "leptess", "libc", "memmap2", "png 0.17.16", @@ -669,6 +701,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -696,6 +737,17 @@ dependencies = [ "libc", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.60" @@ -1156,6 +1208,12 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emath" version = "0.29.1" @@ -1513,6 +1571,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.13.1" @@ -1709,6 +1773,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1922,12 +1995,56 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "leptess" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8964e3d3270be667dda2d0026e8c77011bafaad33936011b93750489987513" +dependencies = [ + "tesseract-plumbing", + "thiserror 1.0.69", +] + +[[package]] +name = "leptonica-plumbing" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7a74c43d6f090d39158d233f326f47cd8bba545217595c93662b4e31156f42" +dependencies = [ + "leptonica-sys", + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "leptonica-sys" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da627c72b2499a8106f4dd33143843015e4a631f445d561f3481f7fba35b6151" +dependencies = [ + "bindgen", + "pkg-config", + "vcpkg", +] + [[package]] name = "libc" version = "0.2.182" @@ -2067,6 +2184,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2166,6 +2289,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2578,6 +2711,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2906,6 +3045,35 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3270,6 +3438,29 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "tesseract-plumbing" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25fbbb95169954a9262a565fbfb001c4d9dad271d48142e6632a3e2b7314b35" +dependencies = [ + "leptonica-plumbing", + "tesseract-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "tesseract-sys" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd33f6f216124cfaf0fa86c2c0cdf04da39b6257bd78c5e44fa4fa98c3a5857b" +dependencies = [ + "bindgen", + "leptonica-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3414,7 +3605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", + "nom 8.0.0", "petgraph", ] @@ -3504,6 +3695,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3949,6 +4146,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 967daab..f17d820 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ required-features = ["gui"] [features] default = [] gui = ["dep:eframe", "dep:egui", "dep:egui_extras", "dep:ab_glyph"] +ocr = ["dep:leptess"] [dependencies] clap = { version = "4", features = ["derive"] } @@ -44,6 +45,7 @@ ab_glyph = { version = "0.2", optional = true } eframe = { version = "0.29", features = ["wayland"], optional = true } egui = { version = "0.29", optional = true } egui_extras = { version = "0.29", features = ["image"], optional = true } +leptess = { version = "0.14", optional = true } [profile.release] lto = "fat" diff --git a/src/capture.rs b/src/capture.rs index 2d05ec4..fee84f6 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -333,20 +333,19 @@ impl AppState { // Capture all monitors in parallel — each thread opens its own // Wayland connection. This turns N sequential captures (each // gated on a compositor roundtrip) into N concurrent ones. - let captures: Vec>> = - std::thread::scope(|s| { - let handles: Vec<_> = mons - .iter() - .map(|mon| { - s.spawn(move || { - let lw = (mon.width as f64 / mon.scale).round() as u32; - let lh = (mon.height as f64 / mon.scale).round() as u32; - capture_and_resize(&mon.name, opts, lw, lh, false) - }) + let captures: Vec>> = std::thread::scope(|s| { + let handles: Vec<_> = mons + .iter() + .map(|mon| { + s.spawn(move || { + let lw = (mon.width as f64 / mon.scale).round() as u32; + let lh = (mon.height as f64 / mon.scale).round() as u32; + capture_and_resize(&mon.name, opts, lw, lh, false) }) - .collect(); - handles.into_iter().map(|h| h.join().unwrap()).collect() - }); + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); let mut canvas = image::RgbaImage::new(logical_total_w, logical_total_h); for (mon, c) in mons.iter().zip(captures.into_iter()) { @@ -673,11 +672,12 @@ fn capture_and_resize( let mut state = state; let overlay = if opts.include_cursor { 1 } else { 0 }; - let frame = state - .screencopy_mgr - .as_ref() - .unwrap() - .capture_output(overlay, &out_handle, &state.qh, ()); + let frame = + state + .screencopy_mgr + .as_ref() + .unwrap() + .capture_output(overlay, &out_handle, &state.qh, ()); state.frame = Some(frame); state.frame_state = FrameState::Negotiating; state.dispatch_to_ready()?; diff --git a/src/clipboard.rs b/src/clipboard.rs index e45ec7f..a55c3fe 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -14,3 +14,12 @@ pub fn copy_png(png_bytes: Vec) -> Result<()> { thread::sleep(std::time::Duration::from_millis(50)); Ok(()) } + +#[allow(dead_code)] +pub fn copy_text(text: String) -> Result<()> { + Options::new() + .copy(Source::Bytes(text.into_bytes().into()), MimeType::Text) + .map_err(|e| BlastError::Clipboard(format!("{e}")))?; + thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) +} diff --git a/src/gui/annotations.rs b/src/gui/annotations.rs index 01fd7da..3e44a04 100644 --- a/src/gui/annotations.rs +++ b/src/gui/annotations.rs @@ -213,7 +213,15 @@ impl Annotation for ArrowAnn { fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { let c = to_rgba(self.color); draw_line_img( - img, self.from[0], self.from[1], self.to[0], self.to[1], self.thickness, c, iw, ih, + img, + self.from[0], + self.from[1], + self.to[0], + self.to[1], + self.thickness, + c, + iw, + ih, ); draw_arrowhead( img, @@ -282,7 +290,14 @@ impl Annotation for TextAnn { } fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { draw_text_img( - img, self.pos[0], self.pos[1], &self.text, self.size, self.color, iw, ih, + img, + self.pos[0], + self.pos[1], + &self.text, + self.size, + self.color, + iw, + ih, ); } fn as_text(&self) -> Option<&TextAnn> { @@ -340,7 +355,9 @@ impl Annotation for PixelateAnn { rect_selection(&self.r, painter, to_screen); } fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { - pixelate_region(img, self.r[0], self.r[1], self.r[2], self.r[3], self.block, iw, ih); + pixelate_region( + img, self.r[0], self.r[1], self.r[2], self.r[3], self.block, iw, ih, + ); } } @@ -386,7 +403,17 @@ impl Annotation for PencilAnn { fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { let c = to_rgba(self.color); for w in self.points.windows(2) { - draw_line_img(img, w[0][0], w[0][1], w[1][0], w[1][1], self.thickness, c, iw, ih); + draw_line_img( + img, + w[0][0], + w[0][1], + w[1][0], + w[1][1], + self.thickness, + c, + iw, + ih, + ); } } } diff --git a/src/gui/app.rs b/src/gui/app.rs index ee68061..49ffa15 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -15,8 +15,9 @@ use crate::{ capture::{self, CaptureOptions}, clipboard, error::BlastError, - freeze, hyprland, paths, region, select, wayland_windows, + freeze, hyprland, paths, region, select, shhh::{self, ShadowOptions}, + wayland_windows, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -204,23 +205,25 @@ impl BlastApp { Subject::Area => { let geom = hyprland::with_animations_disabled(|bs| { let boxes = wayland_windows::visible_window_hints().unwrap_or_else(|| { - let bar = hyprland::get_option_int("plugin:hyprbars:bar_height") - .unwrap_or(0); + let bar = + hyprland::get_option_int("plugin:hyprbars:bar_height").unwrap_or(0); hyprland::visible_windows() .map(|wins| { wins.iter() .map(|w| { let g = w.to_geometry(bs, bar); select::HintBox { - x: g.x as i32, y: g.y as i32, - w: g.w as i32, h: g.h as i32, + x: g.x as i32, + y: g.y as i32, + w: g.w as i32, + h: g.h as i32, } }) .collect() }) .unwrap_or_default() }); - region::select_area_boxes(boxes, None) + region::select_area_boxes(boxes, None, true) }) .map_err(|e| e.to_string())?; freeze_guard.kill(); @@ -228,7 +231,7 @@ impl BlastApp { (img, "Area".into()) } Subject::Region => { - let geom = region::select_free_region(None).map_err(|e| e.to_string())?; + let geom = region::select_free_region(None, true).map_err(|e| e.to_string())?; freeze_guard.kill(); let img = capture::capture_region(geom, &opts).map_err(|e| e.to_string())?; (img, "Region".into()) diff --git a/src/main.rs b/src/main.rs index 0d947be..a8e56b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ mod error; mod freeze; mod hyprland; mod notify; +#[cfg(feature = "ocr")] +mod ocr; mod paths; mod region; mod select; @@ -46,6 +48,21 @@ struct Cli { #[arg(long = "hint-color", value_name = "RRGGBBAA", value_parser = parse_color)] hint_color: Option, + /// Hide the border around the active selection / hovered hint box. + /// Useful when the outline bleeds into the captured image. + #[arg(long = "no-outline")] + no_outline: bool, + + /// Reuse the geometry from the most recent `region` capture (skips interactive selection). + #[arg(long)] + last: bool, + + /// Send notification with clickable actions (requires --notify). + /// On save/copysave, exposes a "Copy path" button. Process briefly blocks + /// awaiting a click or notification timeout. + #[arg(long, requires = "notify")] + actions: bool, + #[command(flatten)] shadow: ShadowArgs, @@ -153,6 +170,19 @@ enum Action { /// Output file path (defaults to /tmp/.png). file: Option, }, + /// OCR the captured region and copy the recognized text to the clipboard. + #[cfg(feature = "ocr")] + Ocr { + #[arg(value_enum, default_value_t = Subject::Region)] + subject: Subject, + /// Tesseract language code(s), e.g. "eng" or "eng+deu" (default "eng"). + #[arg(long, default_value = "eng")] + lang: String, + /// Write text to this file instead of (or in addition to) clipboard. + /// Use '-' for stdout. + #[arg(short = 'o', long)] + output: Option, + }, Check, } @@ -183,6 +213,9 @@ fn main() { wait_secs: cli.wait, scale: cli.scale, hint_color: cli.hint_color, + no_outline: cli.no_outline, + last: cli.last, + actions: cli.actions, shadow: cli.shadow, }; @@ -211,6 +244,7 @@ fn print_usage() { println!(" -w, --wait N Wait N seconds before capture"); println!(" -s, --scale SCALE Scale factor"); println!(" --hint-color RRGGBBAA Hint-box colour for area/region (e.g. ff550080)"); + println!(" --no-outline Hide the selection border (avoids outline in captures)"); println!(" --no-shadow Disable drop shadow and corner rounding"); println!(" --radius px Corner radius (default: from Hyprland config or 0)"); println!(" --shadow-offset x,y Shadow offset (default: -20,-20)"); @@ -226,6 +260,9 @@ struct Context { wait_secs: Option, scale: Option, hint_color: Option, + no_outline: bool, + last: bool, + actions: bool, shadow: ShadowArgs, } @@ -257,6 +294,31 @@ impl Context { } } + /// Notify after a successful save. If `--actions` is enabled, expose a + /// "Copy path" button; on click, copy `saved_path` to the clipboard. + fn notify_saved(&self, summary: &str, saved_path: &str) { + if !self.notify { + return; + } + if self.actions { + let actions = [("copy-path", "Copy path")]; + match notify::notify_with_actions( + summary, + Some(saved_path), + Some(saved_path), + &actions, + 3000, + ) { + Ok(Some(key)) if key == "copy-path" => { + let _ = clipboard::copy_text(saved_path.to_string()); + } + _ => {} + } + } else { + let _ = notify::notify_ok(summary, Some(saved_path), Some(saved_path)); + } + } + /// Encode image to PNG, applying shadow/rounding if enabled. fn finalize_png(&self, img: &DynamicImage) -> Result> { match self.shadow.resolve() { @@ -280,6 +342,55 @@ fn run(action: Action, ctx: Context) -> Result<()> { run_copysave(subject, output.or(file), ctx) } Action::Edit { subject, file } => run_edit(subject, file, ctx), + #[cfg(feature = "ocr")] + Action::Ocr { + subject, + lang, + output, + } => run_ocr(subject, lang, output, ctx), + } +} + +#[cfg(feature = "ocr")] +fn run_ocr(subject: Subject, lang: String, output: Option, ctx: Context) -> Result<()> { + let (img, _what) = capture_subject(&subject, &ctx)?; + let text = ocr::recognize(&img, &lang)?; + + if text.is_empty() { + ctx.notify_err("OCR: no text recognized", None); + return Err(BlastError::Other("no text recognized".into())); + } + + match output.as_deref() { + Some("-") => { + print!("{text}"); + } + Some(path) => { + std::fs::write(path, &text).map_err(BlastError::Io)?; + ctx.notify_ok("Text saved", Some(path), None); + println!("{path}"); + } + None => { + clipboard::copy_text(text.clone()).map_err(|e| { + ctx.notify_err("Clipboard error", Some(&e.to_string())); + e + })?; + ctx.notify_ok("Text copied", Some(&summarize_text(&text)), None); + // also print to stdout so it's pipe-able + println!("{text}"); + } + } + + Ok(()) +} + +#[cfg(feature = "ocr")] +fn summarize_text(t: &str) -> String { + let one_line: String = t.split_whitespace().collect::>().join(" "); + if one_line.len() <= 80 { + one_line + } else { + format!("{}…", &one_line[..77]) } } @@ -340,7 +451,7 @@ fn run_save(subject: Subject, file: Option, ctx: Context) -> Result<()> let png = ctx.finalize_png(&img)?; std::fs::write(&dest, &png).map_err(BlastError::Io)?; let name = dest.display().to_string(); - ctx.notify_ok(&format!("Screenshot of {what}"), Some(&name), Some(&name)); + ctx.notify_saved(&format!("Screenshot of {what}"), &name); println!("{name}"); } } @@ -363,11 +474,7 @@ fn run_copysave(subject: Subject, file: Option, ctx: Context) -> Result< })?; let name = dest.display().to_string(); - ctx.notify_ok( - &format!("{what} copied and saved"), - Some(&name), - Some(&name), - ); + ctx.notify_saved(&format!("{what} copied and saved"), &name); println!("{name}"); Ok(()) } @@ -477,11 +584,11 @@ fn capture_area(ctx: &Context) -> Result<(DynamicImage, String)> { }) .collect() }); - region::select_area_boxes(boxes, ctx.hint_color) + region::select_area_boxes(boxes, ctx.hint_color, !ctx.no_outline) })? } else { let boxes = wayland_windows::visible_window_hints().unwrap_or_default(); - region::select_area_boxes(boxes, ctx.hint_color)? + region::select_area_boxes(boxes, ctx.hint_color, !ctx.no_outline)? }; // If the selection snapped to a known Hyprland window, ask the compositor @@ -506,13 +613,22 @@ fn capture_area(ctx: &Context) -> Result<(DynamicImage, String)> { } fn capture_region_free(ctx: &Context) -> Result<(DynamicImage, String)> { + if ctx.last { + let geom = paths::load_last_region().ok_or_else(|| { + BlastError::Selection("no previous region cached (run `region` once first)".into()) + })?; + let img = capture::capture_region(geom, &ctx.capture_opts())?; + return Ok((img, "Region".into())); + } + let mut freeze_guard = if ctx.freeze { freeze::FreezeGuard::spawn()? } else { freeze::FreezeGuard::none() }; - let geom = region::select_free_region(ctx.hint_color)?; + let geom = region::select_free_region(ctx.hint_color, !ctx.no_outline)?; + paths::save_last_region(&geom); let img = capture::capture_region(geom, &ctx.capture_opts())?; freeze_guard.kill(); diff --git a/src/notify.rs b/src/notify.rs index 3a75802..b8d48ad 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -8,7 +8,7 @@ use std::env; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::error::{BlastError, Result}; @@ -28,13 +28,7 @@ pub fn notify_ok(summary: &str, body: Option<&str>, icon: Option<&str>) -> Resul #[allow(dead_code)] pub fn notify_error(summary: &str, body: Option<&str>) -> Result<()> { - send_notify( - summary, - body.unwrap_or(""), - "", - URGENCY_CRITICAL, - 5000, - ) + send_notify(summary, body.unwrap_or(""), "", URGENCY_CRITICAL, 5000) } fn err>(s: S) -> BlastError { @@ -45,13 +39,7 @@ fn io_err(e: std::io::Error) -> BlastError { BlastError::Notify(e.to_string()) } -fn send_notify( - summary: &str, - body: &str, - icon: &str, - urgency: u8, - timeout_ms: i32, -) -> Result<()> { +fn send_notify(summary: &str, body: &str, icon: &str, urgency: u8, timeout_ms: i32) -> Result<()> { let path = session_bus_path().ok_or_else(|| err("no DBus session bus address"))?; let mut sock = UnixStream::connect(&path).map_err(io_err)?; sock.set_read_timeout(Some(Duration::from_secs(2))).ok(); @@ -201,6 +189,20 @@ fn write_empty_string_array(buf: &mut Vec) { buf.extend_from_slice(&0u32.to_le_bytes()); } +/// Marshal an array of strings: u32 byte-length + N (string) entries. +fn write_string_array(buf: &mut Vec, items: &[&str]) { + align_to(buf, 4); + let len_pos = buf.len(); + buf.extend_from_slice(&[0u8; 4]); + // 's' element alignment is 4 (already at 4-aligned position). + let start = buf.len(); + for s in items { + write_string(buf, s); + } + let len = (buf.len() - start) as u32; + buf[len_pos..len_pos + 4].copy_from_slice(&len.to_le_bytes()); +} + /// Marshal `a{sv}` containing a single "urgency" -> byte entry. fn write_urgency_hint(buf: &mut Vec, urgency: u8) { align_to(buf, 4); @@ -240,7 +242,7 @@ fn build_method_call( // Header fields array a(yv) let fields_len_pos = out.len(); out.extend_from_slice(&[0u8; 4]); // placeholder for array byte-length - // Pad to element alignment (struct = 8); this padding is NOT counted in the array length. + // Pad to element alignment (struct = 8); this padding is NOT counted in the array length. align_to(&mut out, 8); let fields_start = out.len(); @@ -297,10 +299,173 @@ mod tests { notify_ok("blast notify test", Some("hand-rolled DBus works"), None) .expect("notify failed"); } + + /// Live test for the actions path. Pops a notification with a "Copy path" + /// button. Returns Some("copy-path") if you click it, None on timeout. + /// Run with: cargo test --bin blast notify::tests::live_actions -- --ignored --nocapture + #[test] + #[ignore] + fn live_actions() { + let result = notify_with_actions( + "blast actions test", + Some("click \"Copy path\" within 5 seconds"), + None, + &[("copy-path", "Copy path")], + 5000, + ) + .expect("notify_with_actions failed"); + println!("result: {:?}", result); + } } /// Read and discard one DBus message (used to drain the Hello reply). fn drain_message(sock: &mut UnixStream) -> Result<()> { + let _ = read_message(sock)?; + Ok(()) +} + +// === Interactive notifications (actions) === + +/// Send a notification with clickable actions, then wait up to `expire_ms`+500ms for the +/// user to click one. Returns `Some(action_key)` on click, `None` on dismissal or timeout. +/// +/// `actions` is a list of `(key, label)` pairs — e.g. `[("open", "Open"), ("copy-path", "Copy path")]`. +#[allow(dead_code)] +pub fn notify_with_actions( + summary: &str, + body: Option<&str>, + icon: Option<&str>, + actions: &[(&str, &str)], + expire_ms: i32, +) -> Result> { + let path = session_bus_path().ok_or_else(|| err("no DBus session bus address"))?; + let mut sock = UnixStream::connect(&path).map_err(io_err)?; + sock.set_write_timeout(Some(Duration::from_secs(2))).ok(); + + sasl_auth(&mut sock)?; + hello(&mut sock)?; + add_match( + &mut sock, + "type='signal',interface='org.freedesktop.Notifications'", + )?; + let notif_id = do_notify_with_actions( + &mut sock, + summary, + body.unwrap_or(""), + icon.unwrap_or(""), + URGENCY_NORMAL, + expire_ms, + actions, + )?; + + // Wait for ActionInvoked or NotificationClosed for our id, or timeout. + // Bus may interleave other signals (NameAcquired etc) — we just skip them. + let deadline = Instant::now() + Duration::from_millis(expire_ms.max(0) as u64 + 500); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + sock.set_read_timeout(Some(remaining)).ok(); + + let msg = match read_message(&mut sock) { + Ok(m) => m, + Err(_) => return Ok(None), // timed out or socket error + }; + + match msg.member.as_deref() { + Some("ActionInvoked") => { + if let Some((id, key)) = parse_us_body(&msg.body) { + if id == notif_id { + return Ok(Some(key)); + } + } + } + Some("NotificationClosed") => { + if msg.body.len() >= 4 { + let id = u32::from_le_bytes(msg.body[0..4].try_into().unwrap()); + if id == notif_id { + return Ok(None); + } + } + } + _ => {} + } + } +} + +fn add_match(sock: &mut UnixStream, rule: &str) -> Result<()> { + let mut body = Vec::new(); + write_string(&mut body, rule); + let msg = build_method_call( + 3, + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "AddMatch", + "org.freedesktop.DBus", + Some("s"), + &body, + 0, + ); + sock.write_all(&msg).map_err(io_err)?; + drain_message(sock)?; + Ok(()) +} + +fn do_notify_with_actions( + sock: &mut UnixStream, + summary: &str, + body: &str, + icon: &str, + urgency: u8, + timeout_ms: i32, + actions: &[(&str, &str)], +) -> Result { + let mut payload = Vec::new(); + write_string(&mut payload, "blast"); + write_u32(&mut payload, 0); + write_string(&mut payload, icon); + write_string(&mut payload, summary); + write_string(&mut payload, body); + + // Flatten (key,label) pairs into a flat string array. + let mut flat: Vec<&str> = Vec::with_capacity(actions.len() * 2); + for (k, l) in actions { + flat.push(k); + flat.push(l); + } + write_string_array(&mut payload, &flat); + write_urgency_hint(&mut payload, urgency); + align_to(&mut payload, 4); + payload.extend_from_slice(&timeout_ms.to_le_bytes()); + + let msg = build_method_call( + 4, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "Notify", + "org.freedesktop.Notifications", + Some("susssasa{sv}i"), + &payload, + 0, // NOT fire-and-forget — we need the reply to learn our notification id. + ); + sock.write_all(&msg).map_err(io_err)?; + + let reply = read_message(sock)?; + if reply.body.len() < 4 { + return Err(err("notify reply: body too short")); + } + Ok(u32::from_le_bytes(reply.body[0..4].try_into().unwrap())) +} + +// === Message unmarshalling === + +struct Message { + member: Option, + body: Vec, +} + +fn read_message(sock: &mut UnixStream) -> Result { let mut fixed = [0u8; 16]; sock.read_exact(&mut fixed).map_err(io_err)?; if fixed[0] != b'l' { @@ -308,10 +473,100 @@ fn drain_message(sock: &mut UnixStream) -> Result<()> { } let body_len = u32::from_le_bytes(fixed[4..8].try_into().unwrap()) as usize; let fields_len = u32::from_le_bytes(fixed[12..16].try_into().unwrap()) as usize; - // Header is always padded to 8 before body (whether body is empty or not). let header_pad = (8 - ((16 + fields_len) % 8)) % 8; - let total_after = fields_len + header_pad + body_len; - let mut discard = vec![0u8; total_after]; - sock.read_exact(&mut discard).map_err(io_err)?; - Ok(()) + + let mut fields = vec![0u8; fields_len]; + sock.read_exact(&mut fields).map_err(io_err)?; + let mut pad = vec![0u8; header_pad]; + if !pad.is_empty() { + sock.read_exact(&mut pad).map_err(io_err)?; + } + let mut body = vec![0u8; body_len]; + if !body.is_empty() { + sock.read_exact(&mut body).map_err(io_err)?; + } + + let member = parse_member(&fields); + Ok(Message { member, body }) +} + +/// Walk the header fields array looking for the MEMBER field (code=3, sig "s"). +/// Returns None if not present or if parsing falls off the end of a known type. +fn parse_member(fields: &[u8]) -> Option { + let mut pos = 0; + while pos < fields.len() { + pos = align_up(pos, 8); + if pos >= fields.len() { + break; + } + let code = fields[pos]; + pos += 1; + if pos >= fields.len() { + return None; + } + let sig_len = fields[pos] as usize; + pos += 1; + if pos + sig_len + 1 > fields.len() { + return None; + } + let sig = fields[pos..pos + sig_len].to_vec(); + pos += sig_len + 1; // include trailing NUL + + if code == 3 && sig.first() == Some(&b's') { + pos = align_up(pos, 4); + if pos + 4 > fields.len() { + return None; + } + let slen = u32::from_le_bytes(fields[pos..pos + 4].try_into().ok()?) as usize; + pos += 4; + if pos + slen > fields.len() { + return None; + } + return Some(String::from_utf8_lossy(&fields[pos..pos + slen]).into_owned()); + } + pos = skip_variant_value(&sig, fields, pos)?; + } + None +} + +fn skip_variant_value(sig: &[u8], fields: &[u8], mut pos: usize) -> Option { + match sig.first()? { + b'o' | b's' => { + pos = align_up(pos, 4); + if pos + 4 > fields.len() { + return None; + } + let slen = u32::from_le_bytes(fields[pos..pos + 4].try_into().ok()?) as usize; + Some(pos + 4 + slen + 1) + } + b'g' => { + if pos >= fields.len() { + return None; + } + let glen = fields[pos] as usize; + Some(pos + 1 + glen + 1) + } + b'u' | b'i' => { + pos = align_up(pos, 4); + Some(pos + 4) + } + _ => None, + } +} + +fn align_up(pos: usize, n: usize) -> usize { + (pos + n - 1) & !(n - 1) +} + +/// Parse a DBus body with signature "us": u32 id + string. Used for ActionInvoked. +fn parse_us_body(body: &[u8]) -> Option<(u32, String)> { + if body.len() < 8 { + return None; + } + let id = u32::from_le_bytes(body[0..4].try_into().ok()?); + let slen = u32::from_le_bytes(body[4..8].try_into().ok()?) as usize; + if body.len() < 8 + slen { + return None; + } + Some((id, String::from_utf8_lossy(&body[8..8 + slen]).into_owned())) } diff --git a/src/ocr.rs b/src/ocr.rs new file mode 100644 index 0000000..29444b3 --- /dev/null +++ b/src/ocr.rs @@ -0,0 +1,32 @@ +//! OCR via libtesseract (dynamic link, gated by the `ocr` cargo feature). +//! +//! `leptess` wraps `tesseract-sys` (bindgen-generated bindings to libtesseract + +//! libleptonica). At build time bindgen reads /usr/include/tesseract/*.h and emits +//! the Rust extern declarations; at runtime we dynamic-link against +//! `libtesseract.so` / `libleptonica.so` already present on the system. + +use image::DynamicImage; +use leptess::LepTess; + +use crate::error::{BlastError, Result}; +use crate::shhh::encode_png; + +/// Run OCR on `img` using the given language tag (e.g. "eng", "eng+deu"). +/// Returns the recognized text with trailing whitespace trimmed. +/// +/// leptess only exposes `set_image_from_mem` (decodes via leptonica), so we +/// round-trip through PNG. Re-encoding is cheap relative to OCR runtime. +pub fn recognize(img: &DynamicImage, language: &str) -> Result { + let mut lt = LepTess::new(None, language) + .map_err(|e| BlastError::Other(format!("tesseract init: {e}")))?; + + let png = encode_png(img).map_err(|e| BlastError::Image(e.to_string()))?; + lt.set_image_from_mem(&png) + .map_err(|e| BlastError::Other(format!("tesseract set_image: {e}")))?; + + let text = lt + .get_utf8_text() + .map_err(|e| BlastError::Other(format!("tesseract recognize: {e}")))?; + + Ok(text.trim().to_string()) +} diff --git a/src/paths.rs b/src/paths.rs index 18f6c63..1eb566e 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,9 +1,11 @@ //! File path helpers: XDG screenshot dir, random names, editor tmp dir. -use std::{env, path::PathBuf}; +use std::{env, fs, path::PathBuf}; use rand::{distributions::Alphanumeric, Rng}; +use crate::hyprland::Geometry; + /// Resolve the target directory for saving screenshots. /// /// Priority: $XDG_SCREENSHOTS_DIR -> $XDG_PICTURES_DIR -> $HOME. @@ -60,3 +62,31 @@ pub fn default_editor_path() -> PathBuf { pub fn resolve_editor() -> String { env::var("BLAST_EDITOR").unwrap_or_else(|_| "gimp".into()) } + +fn last_region_path() -> Option { + dirs::cache_dir().map(|d| d.join("blast").join("last-region")) +} + +pub fn save_last_region(g: &Geometry) { + let Some(path) = last_region_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, format!("{} {} {} {}\n", g.x, g.y, g.w, g.h)); +} + +pub fn load_last_region() -> Option { + let path = last_region_path()?; + let s = fs::read_to_string(path).ok()?; + let mut parts = s.trim().split_whitespace(); + let x = parts.next()?.parse().ok()?; + let y = parts.next()?.parse().ok()?; + let w = parts.next()?.parse().ok()?; + let h = parts.next()?.parse().ok()?; + if w <= 0 || h <= 0 { + return None; + } + Some(Geometry { x, y, w, h }) +} diff --git a/src/region.rs b/src/region.rs index 28c555c..6276d4f 100644 --- a/src/region.rs +++ b/src/region.rs @@ -5,16 +5,20 @@ use crate::{ }; /// Area selection from a pre-built list of hint boxes (logical coords). -pub fn select_area_boxes(boxes: Vec, hint_rgba: Option) -> Result { +pub fn select_area_boxes( + boxes: Vec, + hint_rgba: Option, + outline: bool, +) -> Result { if boxes.is_empty() { - select::select_region(vec![], false, hint_rgba) + select::select_region(vec![], false, hint_rgba, outline) } else { - select::select_region(boxes, true, hint_rgba) + select::select_region(boxes, true, hint_rgba, outline) } } -pub fn select_free_region(hint_rgba: Option) -> Result { - select::select_region(vec![], false, hint_rgba) +pub fn select_free_region(hint_rgba: Option, outline: bool) -> Result { + select::select_region(vec![], false, hint_rgba, outline) } #[allow(dead_code)] pub fn select_area( @@ -22,9 +26,10 @@ pub fn select_area( border_size: i64, bar_height: i64, hint_rgba: Option, + outline: bool, ) -> Result { if windows.is_empty() { - return select::select_region(vec![], false, hint_rgba); + return select::select_region(vec![], false, hint_rgba, outline); } let boxes = windows .iter() @@ -38,5 +43,5 @@ pub fn select_area( } }) .collect(); - select::select_region(boxes, true, hint_rgba) + select::select_region(boxes, true, hint_rgba, outline) } diff --git a/src/select.rs b/src/select.rs index b438d84..8daac2f 100644 --- a/src/select.rs +++ b/src/select.rs @@ -123,6 +123,7 @@ struct St { hover: Option, restrict: bool, c_hint: [u8; 4], + outline: bool, running: bool, result: Option, @@ -345,6 +346,7 @@ impl St { let buf_size = s.buf_size; let c_hint = self.c_hint; + let outline = self.outline; let box_data: Vec<(i32, i32, i32, i32, bool)> = self .boxes .iter() @@ -369,7 +371,9 @@ impl St { if bx0 < bx1 && by0 < by1 { if *is_hov { fill(data, stride, bx0, by0, bx1, by1, C_SEL); - draw_border(data, stride, pw, ph, bx0, by0, bx1, by1, BDR * scale, C_BDR); + if outline { + draw_border(data, stride, pw, ph, bx0, by0, bx1, by1, BDR * scale, C_BDR); + } } else { fill(data, stride, bx0, by0, bx1, by1, c_hint); } @@ -383,7 +387,9 @@ impl St { let y1 = ((gy + gh - ly) * scale).max(0).min(ph); if x0 < x1 && y0 < y1 { fill(data, stride, x0, y0, x1, y1, C_SEL); - draw_border(data, stride, pw, ph, x0, y0, x1, y1, BDR * scale, C_BDR); + if outline { + draw_border(data, stride, pw, ph, x0, y0, x1, y1, BDR * scale, C_BDR); + } } } @@ -754,6 +760,7 @@ pub fn select_region( boxes: Vec, restrict: bool, hint_rgba: Option, + outline: bool, ) -> Result { let conn = Connection::connect_to_env().map_err(|e| BlastError::Selection(format!("connect: {e}")))?; @@ -786,12 +793,9 @@ pub fn select_region( let pending: Vec = out_globals .iter() .map(|&(gname, ver)| PendingOut { - wl: globals.registry().bind::( - gname, - ver.min(4), - &qh, - (), - ), + wl: globals + .registry() + .bind::(gname, ver.min(4), &qh, ()), name: None, wl_name: gname, ox: 0, @@ -834,6 +838,7 @@ pub fn select_region( hover: None, restrict, c_hint, + outline, running: true, result: None, }; diff --git a/src/shhh.rs b/src/shhh.rs index 601094a..a12ec8b 100644 --- a/src/shhh.rs +++ b/src/shhh.rs @@ -237,8 +237,8 @@ fn create_shadow( for (i, &a) in mask.iter().enumerate() { out_rgba[i * 4 + 3] = lut[a as usize]; } - let buf: ImageBuffer, Vec> = ImageBuffer::from_raw(new_w, new_h, out_rgba) - .expect("shadow buffer construction"); + let buf: ImageBuffer, Vec> = + ImageBuffer::from_raw(new_w, new_h, out_rgba).expect("shadow buffer construction"); DynamicImage::ImageRgba8(buf) } diff --git a/src/toplevel_capture.rs b/src/toplevel_capture.rs index 9b0c557..02c8127 100644 --- a/src/toplevel_capture.rs +++ b/src/toplevel_capture.rs @@ -26,7 +26,9 @@ use wayland_protocols::wp::linux_dmabuf::zv1::client::{ zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1, }; use wayland_protocols_wlr::foreign_toplevel::v1::client::{ - zwlr_foreign_toplevel_handle_v1::{self, State as WlrToplevelState, ZwlrForeignToplevelHandleV1}, + zwlr_foreign_toplevel_handle_v1::{ + self, State as WlrToplevelState, ZwlrForeignToplevelHandleV1, + }, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; @@ -694,8 +696,7 @@ impl Dispatch for St { } hyprland_toplevel_export_frame_v1::Event::Flags { flags } => { if let wayland_client::WEnum::Value(f) = flags { - state.y_invert = f - .contains(hyprland_toplevel_export_frame_v1::Flags::YInvert); + state.y_invert = f.contains(hyprland_toplevel_export_frame_v1::Flags::YInvert); } } hyprland_toplevel_export_frame_v1::Event::Ready { .. } => { diff --git a/src/wayland_windows.rs b/src/wayland_windows.rs index fbf3053..5cbe7af 100644 --- a/src/wayland_windows.rs +++ b/src/wayland_windows.rs @@ -23,7 +23,9 @@ use wayland_protocols::xdg::xdg_output::zv1::client::{ zxdg_output_v1::{self, ZxdgOutputV1}, }; use wayland_protocols_wlr::foreign_toplevel::v1::client::{ - zwlr_foreign_toplevel_handle_v1::{self, State as WlrToplevelState, ZwlrForeignToplevelHandleV1}, + zwlr_foreign_toplevel_handle_v1::{ + self, State as WlrToplevelState, ZwlrForeignToplevelHandleV1, + }, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, };