select: add --no-outline to hide selection border
This commit is contained in:
parent
b58c2df1d9
commit
30f9180ffe
15 changed files with 778 additions and 82 deletions
211
Cargo.lock
generated
211
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<Result<Option<image::RgbaImage>>> =
|
||||
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<Result<Option<image::RgbaImage>>> = 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()?;
|
||||
|
|
|
|||
|
|
@ -14,3 +14,12 @@ pub fn copy_png(png_bytes: Vec<u8>) -> 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
134
src/main.rs
134
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<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)]
|
||||
shadow: ShadowArgs,
|
||||
|
||||
|
|
@ -153,6 +170,19 @@ enum Action {
|
|||
/// Output file path (defaults to /tmp/<timestamp>.png).
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
@ -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<u64>,
|
||||
scale: Option<f64>,
|
||||
hint_color: Option<u32>,
|
||||
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<Vec<u8>> {
|
||||
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<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)?;
|
||||
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<String>, 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();
|
||||
|
|
|
|||
297
src/notify.rs
297
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: Into<String>>(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<u8>) {
|
|||
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.
|
||||
fn write_urgency_hint(buf: &mut Vec<u8>, 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<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];
|
||||
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<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
32
src/ocr.rs
Normal 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())
|
||||
}
|
||||
32
src/paths.rs
32
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<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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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<u32>) -> Result<Geometry> {
|
||||
select::select_region(vec![], false, hint_rgba)
|
||||
pub fn select_free_region(hint_rgba: Option<u32>, outline: bool) -> Result<Geometry> {
|
||||
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<u32>,
|
||||
outline: bool,
|
||||
) -> Result<Geometry> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ struct St {
|
|||
hover: Option<usize>,
|
||||
restrict: bool,
|
||||
c_hint: [u8; 4],
|
||||
outline: bool,
|
||||
|
||||
running: bool,
|
||||
result: Option<Geometry>,
|
||||
|
|
@ -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<HintBox>,
|
||||
restrict: bool,
|
||||
hint_rgba: Option<u32>,
|
||||
outline: bool,
|
||||
) -> Result<Geometry> {
|
||||
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<PendingOut> = out_globals
|
||||
.iter()
|
||||
.map(|&(gname, ver)| PendingOut {
|
||||
wl: globals.registry().bind::<wl_output::WlOutput, _, _>(
|
||||
gname,
|
||||
ver.min(4),
|
||||
&qh,
|
||||
(),
|
||||
),
|
||||
wl: globals
|
||||
.registry()
|
||||
.bind::<wl_output::WlOutput, _, _>(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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Rgba<u8>, Vec<u8>> = ImageBuffer::from_raw(new_w, new_h, out_rgba)
|
||||
.expect("shadow buffer construction");
|
||||
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_raw(new_w, new_h, out_rgba).expect("shadow buffer construction");
|
||||
DynamicImage::ImageRgba8(buf)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HyprlandToplevelExportFrameV1, ()> 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 { .. } => {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue