Optional OCR feature for visually impaired #1

Merged
entailz merged 1 commit from feat/ocr-window-picker-actions into master 2026-05-22 16:21:40 -04:00
15 changed files with 778 additions and 82 deletions

211
Cargo.lock generated
View file

@ -127,6 +127,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.0" version = "0.6.0"
@ -468,6 +477,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.6.0" version = "0.6.0"
@ -509,6 +540,7 @@ dependencies = [
"egui_extras", "egui_extras",
"gbm", "gbm",
"image", "image",
"leptess",
"libc", "libc",
"memmap2", "memmap2",
"png 0.17.16", "png 0.17.16",
@ -669,6 +701,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@ -696,6 +737,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "clap" name = "clap"
version = "4.5.60" version = "4.5.60"
@ -1156,6 +1208,12 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "emath" name = "emath"
version = "0.29.1" version = "0.29.1"
@ -1513,6 +1571,12 @@ dependencies = [
"xml-rs", "xml-rs",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.13.1" version = "0.13.1"
@ -1709,6 +1773,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@ -1922,12 +1995,56 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" 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]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.182" version = "0.2.182"
@ -2067,6 +2184,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -2166,6 +2289,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" 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]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@ -2578,6 +2711,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "peeking_take_while"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -2906,6 +3045,35 @@ dependencies = [
"thiserror 1.0.69", "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]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@ -3270,6 +3438,29 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -3414,7 +3605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [ dependencies = [
"memchr", "memchr",
"nom", "nom 8.0.0",
"petgraph", "petgraph",
] ]
@ -3504,6 +3695,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -3949,6 +4146,18 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "widestring" name = "widestring"
version = "1.2.1" version = "1.2.1"

View file

@ -18,6 +18,7 @@ required-features = ["gui"]
[features] [features]
default = [] default = []
gui = ["dep:eframe", "dep:egui", "dep:egui_extras", "dep:ab_glyph"] gui = ["dep:eframe", "dep:egui", "dep:egui_extras", "dep:ab_glyph"]
ocr = ["dep:leptess"]
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
@ -44,6 +45,7 @@ ab_glyph = { version = "0.2", optional = true }
eframe = { version = "0.29", features = ["wayland"], optional = true } eframe = { version = "0.29", features = ["wayland"], optional = true }
egui = { version = "0.29", optional = true } egui = { version = "0.29", optional = true }
egui_extras = { version = "0.29", features = ["image"], optional = true } egui_extras = { version = "0.29", features = ["image"], optional = true }
leptess = { version = "0.14", optional = true }
[profile.release] [profile.release]
lto = "fat" lto = "fat"

View file

@ -333,20 +333,19 @@ impl AppState {
// Capture all monitors in parallel — each thread opens its own // Capture all monitors in parallel — each thread opens its own
// Wayland connection. This turns N sequential captures (each // Wayland connection. This turns N sequential captures (each
// gated on a compositor roundtrip) into N concurrent ones. // gated on a compositor roundtrip) into N concurrent ones.
let captures: Vec<Result<Option<image::RgbaImage>>> = let captures: Vec<Result<Option<image::RgbaImage>>> = std::thread::scope(|s| {
std::thread::scope(|s| { let handles: Vec<_> = mons
let handles: Vec<_> = mons .iter()
.iter() .map(|mon| {
.map(|mon| { s.spawn(move || {
s.spawn(move || { let lw = (mon.width as f64 / mon.scale).round() as u32;
let lw = (mon.width as f64 / mon.scale).round() as u32; let lh = (mon.height 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)
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); let mut canvas = image::RgbaImage::new(logical_total_w, logical_total_h);
for (mon, c) in mons.iter().zip(captures.into_iter()) { for (mon, c) in mons.iter().zip(captures.into_iter()) {
@ -673,11 +672,12 @@ fn capture_and_resize(
let mut state = state; let mut state = state;
let overlay = if opts.include_cursor { 1 } else { 0 }; let overlay = if opts.include_cursor { 1 } else { 0 };
let frame = state let frame =
.screencopy_mgr state
.as_ref() .screencopy_mgr
.unwrap() .as_ref()
.capture_output(overlay, &out_handle, &state.qh, ()); .unwrap()
.capture_output(overlay, &out_handle, &state.qh, ());
state.frame = Some(frame); state.frame = Some(frame);
state.frame_state = FrameState::Negotiating; state.frame_state = FrameState::Negotiating;
state.dispatch_to_ready()?; state.dispatch_to_ready()?;

View file

@ -14,3 +14,12 @@ pub fn copy_png(png_bytes: Vec<u8>) -> Result<()> {
thread::sleep(std::time::Duration::from_millis(50)); thread::sleep(std::time::Duration::from_millis(50));
Ok(()) 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(())
}

View file

@ -213,7 +213,15 @@ impl Annotation for ArrowAnn {
fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) {
let c = to_rgba(self.color); let c = to_rgba(self.color);
draw_line_img( 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( draw_arrowhead(
img, img,
@ -282,7 +290,14 @@ impl Annotation for TextAnn {
} }
fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) {
draw_text_img( 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> { fn as_text(&self) -> Option<&TextAnn> {
@ -340,7 +355,9 @@ impl Annotation for PixelateAnn {
rect_selection(&self.r, painter, to_screen); rect_selection(&self.r, painter, to_screen);
} }
fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) { 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) { fn flatten(&self, img: &mut image::RgbaImage, iw: u32, ih: u32) {
let c = to_rgba(self.color); let c = to_rgba(self.color);
for w in self.points.windows(2) { 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,
);
} }
} }
} }

View file

@ -15,8 +15,9 @@ use crate::{
capture::{self, CaptureOptions}, capture::{self, CaptureOptions},
clipboard, clipboard,
error::BlastError, error::BlastError,
freeze, hyprland, paths, region, select, wayland_windows, freeze, hyprland, paths, region, select,
shhh::{self, ShadowOptions}, shhh::{self, ShadowOptions},
wayland_windows,
}; };
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@ -204,23 +205,25 @@ impl BlastApp {
Subject::Area => { Subject::Area => {
let geom = hyprland::with_animations_disabled(|bs| { let geom = hyprland::with_animations_disabled(|bs| {
let boxes = wayland_windows::visible_window_hints().unwrap_or_else(|| { let boxes = wayland_windows::visible_window_hints().unwrap_or_else(|| {
let bar = hyprland::get_option_int("plugin:hyprbars:bar_height") let bar =
.unwrap_or(0); hyprland::get_option_int("plugin:hyprbars:bar_height").unwrap_or(0);
hyprland::visible_windows() hyprland::visible_windows()
.map(|wins| { .map(|wins| {
wins.iter() wins.iter()
.map(|w| { .map(|w| {
let g = w.to_geometry(bs, bar); let g = w.to_geometry(bs, bar);
select::HintBox { select::HintBox {
x: g.x as i32, y: g.y as i32, x: g.x as i32,
w: g.w as i32, h: g.h as i32, y: g.y as i32,
w: g.w as i32,
h: g.h as i32,
} }
}) })
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default()
}); });
region::select_area_boxes(boxes, None) region::select_area_boxes(boxes, None, true)
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
freeze_guard.kill(); freeze_guard.kill();
@ -228,7 +231,7 @@ impl BlastApp {
(img, "Area".into()) (img, "Area".into())
} }
Subject::Region => { 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(); freeze_guard.kill();
let img = capture::capture_region(geom, &opts).map_err(|e| e.to_string())?; let img = capture::capture_region(geom, &opts).map_err(|e| e.to_string())?;
(img, "Region".into()) (img, "Region".into())

View file

@ -4,6 +4,8 @@ mod error;
mod freeze; mod freeze;
mod hyprland; mod hyprland;
mod notify; mod notify;
#[cfg(feature = "ocr")]
mod ocr;
mod paths; mod paths;
mod region; mod region;
mod select; mod select;
@ -46,6 +48,21 @@ struct Cli {
#[arg(long = "hint-color", value_name = "RRGGBBAA", value_parser = parse_color)] #[arg(long = "hint-color", value_name = "RRGGBBAA", value_parser = parse_color)]
hint_color: Option<u32>, hint_color: Option<u32>,
/// 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)] #[command(flatten)]
shadow: ShadowArgs, shadow: ShadowArgs,
@ -153,6 +170,19 @@ enum Action {
/// Output file path (defaults to /tmp/<timestamp>.png). /// Output file path (defaults to /tmp/<timestamp>.png).
file: Option<String>, file: Option<String>,
}, },
/// 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<String>,
},
Check, Check,
} }
@ -183,6 +213,9 @@ fn main() {
wait_secs: cli.wait, wait_secs: cli.wait,
scale: cli.scale, scale: cli.scale,
hint_color: cli.hint_color, hint_color: cli.hint_color,
no_outline: cli.no_outline,
last: cli.last,
actions: cli.actions,
shadow: cli.shadow, shadow: cli.shadow,
}; };
@ -211,6 +244,7 @@ fn print_usage() {
println!(" -w, --wait N Wait N seconds before capture"); println!(" -w, --wait N Wait N seconds before capture");
println!(" -s, --scale SCALE Scale factor"); println!(" -s, --scale SCALE Scale factor");
println!(" --hint-color RRGGBBAA Hint-box colour for area/region (e.g. ff550080)"); 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!(" --no-shadow Disable drop shadow and corner rounding");
println!(" --radius px Corner radius (default: from Hyprland config or 0)"); println!(" --radius px Corner radius (default: from Hyprland config or 0)");
println!(" --shadow-offset x,y Shadow offset (default: -20,-20)"); println!(" --shadow-offset x,y Shadow offset (default: -20,-20)");
@ -226,6 +260,9 @@ struct Context {
wait_secs: Option<u64>, wait_secs: Option<u64>,
scale: Option<f64>, scale: Option<f64>,
hint_color: Option<u32>, hint_color: Option<u32>,
no_outline: bool,
last: bool,
actions: bool,
shadow: ShadowArgs, 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. /// Encode image to PNG, applying shadow/rounding if enabled.
fn finalize_png(&self, img: &DynamicImage) -> Result<Vec<u8>> { fn finalize_png(&self, img: &DynamicImage) -> Result<Vec<u8>> {
match self.shadow.resolve() { match self.shadow.resolve() {
@ -280,6 +342,55 @@ fn run(action: Action, ctx: Context) -> Result<()> {
run_copysave(subject, output.or(file), ctx) run_copysave(subject, output.or(file), ctx)
} }
Action::Edit { subject, file } => run_edit(subject, 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<String>, 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::<Vec<_>>().join(" ");
if one_line.len() <= 80 {
one_line
} else {
format!("{}", &one_line[..77])
} }
} }
@ -340,7 +451,7 @@ fn run_save(subject: Subject, file: Option<String>, ctx: Context) -> Result<()>
let png = ctx.finalize_png(&img)?; let png = ctx.finalize_png(&img)?;
std::fs::write(&dest, &png).map_err(BlastError::Io)?; std::fs::write(&dest, &png).map_err(BlastError::Io)?;
let name = dest.display().to_string(); 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}"); println!("{name}");
} }
} }
@ -363,11 +474,7 @@ fn run_copysave(subject: Subject, file: Option<String>, ctx: Context) -> Result<
})?; })?;
let name = dest.display().to_string(); let name = dest.display().to_string();
ctx.notify_ok( ctx.notify_saved(&format!("{what} copied and saved"), &name);
&format!("{what} copied and saved"),
Some(&name),
Some(&name),
);
println!("{name}"); println!("{name}");
Ok(()) Ok(())
} }
@ -477,11 +584,11 @@ fn capture_area(ctx: &Context) -> Result<(DynamicImage, String)> {
}) })
.collect() .collect()
}); });
region::select_area_boxes(boxes, ctx.hint_color) region::select_area_boxes(boxes, ctx.hint_color, !ctx.no_outline)
})? })?
} else { } else {
let boxes = wayland_windows::visible_window_hints().unwrap_or_default(); 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 // 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)> { 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 { let mut freeze_guard = if ctx.freeze {
freeze::FreezeGuard::spawn()? freeze::FreezeGuard::spawn()?
} else { } else {
freeze::FreezeGuard::none() 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())?; let img = capture::capture_region(geom, &ctx.capture_opts())?;
freeze_guard.kill(); freeze_guard.kill();

View file

@ -8,7 +8,7 @@
use std::env; use std::env;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::time::Duration; use std::time::{Duration, Instant};
use crate::error::{BlastError, Result}; 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)] #[allow(dead_code)]
pub fn notify_error(summary: &str, body: Option<&str>) -> Result<()> { pub fn notify_error(summary: &str, body: Option<&str>) -> Result<()> {
send_notify( send_notify(summary, body.unwrap_or(""), "", URGENCY_CRITICAL, 5000)
summary,
body.unwrap_or(""),
"",
URGENCY_CRITICAL,
5000,
)
} }
fn err<S: Into<String>>(s: S) -> BlastError { fn err<S: Into<String>>(s: S) -> BlastError {
@ -45,13 +39,7 @@ fn io_err(e: std::io::Error) -> BlastError {
BlastError::Notify(e.to_string()) BlastError::Notify(e.to_string())
} }
fn send_notify( fn send_notify(summary: &str, body: &str, icon: &str, urgency: u8, timeout_ms: i32) -> Result<()> {
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 path = session_bus_path().ok_or_else(|| err("no DBus session bus address"))?;
let mut sock = UnixStream::connect(&path).map_err(io_err)?; let mut sock = UnixStream::connect(&path).map_err(io_err)?;
sock.set_read_timeout(Some(Duration::from_secs(2))).ok(); sock.set_read_timeout(Some(Duration::from_secs(2))).ok();
@ -201,6 +189,20 @@ fn write_empty_string_array(buf: &mut Vec<u8>) {
buf.extend_from_slice(&0u32.to_le_bytes()); 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<u8>, 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. /// Marshal `a{sv}` containing a single "urgency" -> byte entry.
fn write_urgency_hint(buf: &mut Vec<u8>, urgency: u8) { fn write_urgency_hint(buf: &mut Vec<u8>, urgency: u8) {
align_to(buf, 4); align_to(buf, 4);
@ -240,7 +242,7 @@ fn build_method_call(
// Header fields array a(yv) // Header fields array a(yv)
let fields_len_pos = out.len(); let fields_len_pos = out.len();
out.extend_from_slice(&[0u8; 4]); // placeholder for array byte-length 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); align_to(&mut out, 8);
let fields_start = out.len(); let fields_start = out.len();
@ -297,10 +299,173 @@ mod tests {
notify_ok("blast notify test", Some("hand-rolled DBus works"), None) notify_ok("blast notify test", Some("hand-rolled DBus works"), None)
.expect("notify failed"); .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). /// Read and discard one DBus message (used to drain the Hello reply).
fn drain_message(sock: &mut UnixStream) -> Result<()> { 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<Option<String>> {
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<u32> {
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<String>,
body: Vec<u8>,
}
fn read_message(sock: &mut UnixStream) -> Result<Message> {
let mut fixed = [0u8; 16]; let mut fixed = [0u8; 16];
sock.read_exact(&mut fixed).map_err(io_err)?; sock.read_exact(&mut fixed).map_err(io_err)?;
if fixed[0] != b'l' { 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 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; 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 header_pad = (8 - ((16 + fields_len) % 8)) % 8;
let total_after = fields_len + header_pad + body_len;
let mut discard = vec![0u8; total_after]; let mut fields = vec![0u8; fields_len];
sock.read_exact(&mut discard).map_err(io_err)?; sock.read_exact(&mut fields).map_err(io_err)?;
Ok(()) 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<String> {
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<usize> {
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()))
} }

32
src/ocr.rs Normal file
View file

@ -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<String> {
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())
}

View file

@ -1,9 +1,11 @@
//! File path helpers: XDG screenshot dir, random names, editor tmp dir. //! 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 rand::{distributions::Alphanumeric, Rng};
use crate::hyprland::Geometry;
/// Resolve the target directory for saving screenshots. /// Resolve the target directory for saving screenshots.
/// ///
/// Priority: $XDG_SCREENSHOTS_DIR -> $XDG_PICTURES_DIR -> $HOME. /// Priority: $XDG_SCREENSHOTS_DIR -> $XDG_PICTURES_DIR -> $HOME.
@ -60,3 +62,31 @@ pub fn default_editor_path() -> PathBuf {
pub fn resolve_editor() -> String { pub fn resolve_editor() -> String {
env::var("BLAST_EDITOR").unwrap_or_else(|_| "gimp".into()) env::var("BLAST_EDITOR").unwrap_or_else(|_| "gimp".into())
} }
fn last_region_path() -> Option<PathBuf> {
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<Geometry> {
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 })
}

View file

@ -5,16 +5,20 @@ use crate::{
}; };
/// Area selection from a pre-built list of hint boxes (logical coords). /// Area selection from a pre-built list of hint boxes (logical coords).
pub fn select_area_boxes(boxes: Vec<HintBox>, hint_rgba: Option<u32>) -> Result<Geometry> { pub fn select_area_boxes(
boxes: Vec<HintBox>,
hint_rgba: Option<u32>,
outline: bool,
) -> Result<Geometry> {
if boxes.is_empty() { if boxes.is_empty() {
select::select_region(vec![], false, hint_rgba) select::select_region(vec![], false, hint_rgba, outline)
} else { } else {
select::select_region(boxes, true, hint_rgba) select::select_region(boxes, true, hint_rgba, outline)
} }
} }
pub fn select_free_region(hint_rgba: Option<u32>) -> Result<Geometry> { pub fn select_free_region(hint_rgba: Option<u32>, outline: bool) -> Result<Geometry> {
select::select_region(vec![], false, hint_rgba) select::select_region(vec![], false, hint_rgba, outline)
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn select_area( pub fn select_area(
@ -22,9 +26,10 @@ pub fn select_area(
border_size: i64, border_size: i64,
bar_height: i64, bar_height: i64,
hint_rgba: Option<u32>, hint_rgba: Option<u32>,
outline: bool,
) -> Result<Geometry> { ) -> Result<Geometry> {
if windows.is_empty() { if windows.is_empty() {
return select::select_region(vec![], false, hint_rgba); return select::select_region(vec![], false, hint_rgba, outline);
} }
let boxes = windows let boxes = windows
.iter() .iter()
@ -38,5 +43,5 @@ pub fn select_area(
} }
}) })
.collect(); .collect();
select::select_region(boxes, true, hint_rgba) select::select_region(boxes, true, hint_rgba, outline)
} }

View file

@ -123,6 +123,7 @@ struct St {
hover: Option<usize>, hover: Option<usize>,
restrict: bool, restrict: bool,
c_hint: [u8; 4], c_hint: [u8; 4],
outline: bool,
running: bool, running: bool,
result: Option<Geometry>, result: Option<Geometry>,
@ -345,6 +346,7 @@ impl St {
let buf_size = s.buf_size; let buf_size = s.buf_size;
let c_hint = self.c_hint; let c_hint = self.c_hint;
let outline = self.outline;
let box_data: Vec<(i32, i32, i32, i32, bool)> = self let box_data: Vec<(i32, i32, i32, i32, bool)> = self
.boxes .boxes
.iter() .iter()
@ -369,7 +371,9 @@ impl St {
if bx0 < bx1 && by0 < by1 { if bx0 < bx1 && by0 < by1 {
if *is_hov { if *is_hov {
fill(data, stride, bx0, by0, bx1, by1, C_SEL); 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 { } else {
fill(data, stride, bx0, by0, bx1, by1, c_hint); 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); let y1 = ((gy + gh - ly) * scale).max(0).min(ph);
if x0 < x1 && y0 < y1 { if x0 < x1 && y0 < y1 {
fill(data, stride, x0, y0, x1, y1, C_SEL); 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<HintBox>, boxes: Vec<HintBox>,
restrict: bool, restrict: bool,
hint_rgba: Option<u32>, hint_rgba: Option<u32>,
outline: bool,
) -> Result<Geometry> { ) -> Result<Geometry> {
let conn = let conn =
Connection::connect_to_env().map_err(|e| BlastError::Selection(format!("connect: {e}")))?; Connection::connect_to_env().map_err(|e| BlastError::Selection(format!("connect: {e}")))?;
@ -786,12 +793,9 @@ pub fn select_region(
let pending: Vec<PendingOut> = out_globals let pending: Vec<PendingOut> = out_globals
.iter() .iter()
.map(|&(gname, ver)| PendingOut { .map(|&(gname, ver)| PendingOut {
wl: globals.registry().bind::<wl_output::WlOutput, _, _>( wl: globals
gname, .registry()
ver.min(4), .bind::<wl_output::WlOutput, _, _>(gname, ver.min(4), &qh, ()),
&qh,
(),
),
name: None, name: None,
wl_name: gname, wl_name: gname,
ox: 0, ox: 0,
@ -834,6 +838,7 @@ pub fn select_region(
hover: None, hover: None,
restrict, restrict,
c_hint, c_hint,
outline,
running: true, running: true,
result: None, result: None,
}; };

View file

@ -237,8 +237,8 @@ fn create_shadow(
for (i, &a) in mask.iter().enumerate() { for (i, &a) in mask.iter().enumerate() {
out_rgba[i * 4 + 3] = lut[a as usize]; out_rgba[i * 4 + 3] = lut[a as usize];
} }
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::from_raw(new_w, new_h, out_rgba) let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
.expect("shadow buffer construction"); ImageBuffer::from_raw(new_w, new_h, out_rgba).expect("shadow buffer construction");
DynamicImage::ImageRgba8(buf) DynamicImage::ImageRgba8(buf)
} }

View file

@ -26,7 +26,9 @@ use wayland_protocols::wp::linux_dmabuf::zv1::client::{
zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1, zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
}; };
use wayland_protocols_wlr::foreign_toplevel::v1::client::{ 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}, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
}; };
@ -694,8 +696,7 @@ impl Dispatch<HyprlandToplevelExportFrameV1, ()> for St {
} }
hyprland_toplevel_export_frame_v1::Event::Flags { flags } => { hyprland_toplevel_export_frame_v1::Event::Flags { flags } => {
if let wayland_client::WEnum::Value(f) = flags { if let wayland_client::WEnum::Value(f) = flags {
state.y_invert = f state.y_invert = f.contains(hyprland_toplevel_export_frame_v1::Flags::YInvert);
.contains(hyprland_toplevel_export_frame_v1::Flags::YInvert);
} }
} }
hyprland_toplevel_export_frame_v1::Event::Ready { .. } => { hyprland_toplevel_export_frame_v1::Event::Ready { .. } => {

View file

@ -23,7 +23,9 @@ use wayland_protocols::xdg::xdg_output::zv1::client::{
zxdg_output_v1::{self, ZxdgOutputV1}, zxdg_output_v1::{self, ZxdgOutputV1},
}; };
use wayland_protocols_wlr::foreign_toplevel::v1::client::{ 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}, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
}; };