From d6e2d3bced6ade0e8db959f3cdabe749319f52e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:39:57 +0000 Subject: [PATCH] Add screenshot studio: annotation editor, OCR, pin, beautify Replace the gnome-screenshot region capture with RustCast's own capture overlay, modelled on macshot: - Freeze the screen, drag to select or click a window (X11), F for the whole monitor; pixel magnifier with coordinates and colour; resize handles; arrow-key nudging. - 12 annotation tools: arrow, line, rectangle/ellipse (outline or filled), pen, highlighter, text (re-editable), numbered steps, censor (pixelate/blur/solid), spotlight, colour picker, select & move. Undo/redo, 8 colours, 3 sizes, Shift constraints. - Copy, save, save as (png/jpg/webp), pin to screen, beautify (gradient backdrop, padding, rounded corners, shadow). - Copy text from screen (OCR, Super+Shift+T) via the system tesseract, run only for the moment it is needed; crops are sent as grayscale PGM over a pipe, inverted for dark themes and upscaled for small text. Result window with translate / search; QR codes are decoded too. - Launcher commands for window / full screen / quick / delayed capture, annotate any image, rustcast://capture/ for custom keys. - Thumbnail: double-click to annotate, right-click menu (copy, pin, OCR, show in folder, delete), stays while hovered. - Watcher skips dot-files and files still being written. Everything runs in a short-lived `--snap` subprocess, so the resident launcher's memory is unchanged; results reach the clipboard through the running instance so they persist and appear in clipboard history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rYDnQd88iYKgeZGjFCGQT --- Cargo.lock | 81 +- Cargo.toml | 7 +- FEATURES.md | 6 + README.md | 71 +- src/app.rs | 6 + src/app/apps.rs | 74 + src/app/pages/settings.rs | 59 + src/app/screenshot.rs | 78 +- src/app/tile.rs | 4 +- src/app/tile/elm.rs | 1 + src/app/tile/update.rs | 38 + src/commands.rs | 16 +- src/config.rs | 70 + src/main.rs | 12 + src/platform/linux/gnome_hotkeys.rs | 14 +- src/platform/linux/overlay_gtk.rs | 125 +- src/platform/linux/urlscheme.rs | 5 + src/snap/beautify.rs | 239 +++ src/snap/editor.rs | 2561 +++++++++++++++++++++++++++ src/snap/grab.rs | 370 ++++ src/snap/mod.rs | 346 ++++ src/snap/ocr.rs | 506 ++++++ src/snap/pin.rs | 154 ++ src/snap/qr.rs | 34 + 24 files changed, 4801 insertions(+), 76 deletions(-) create mode 100644 src/snap/beautify.rs create mode 100644 src/snap/editor.rs create mode 100644 src/snap/grab.rs create mode 100644 src/snap/mod.rs create mode 100644 src/snap/ocr.rs create mode 100644 src/snap/pin.rs create mode 100644 src/snap/qr.rs diff --git a/Cargo.lock b/Cargo.lock index 700a91e..05f849e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,7 +872,7 @@ checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" dependencies = [ "cosmic-text", "etagere", - "lru", + "lru 0.16.4", "rustc-hash 2.1.2", "wgpu", ] @@ -1375,6 +1375,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "gdk" version = "0.18.2" @@ -1793,6 +1821,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2476,6 +2507,15 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3238,6 +3278,32 @@ dependencies = [ "system-deps", ] +[[package]] +name = "pangocairo" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57036589a9cfcacf83f9e606d15813fc6bf03f0e9e69aa2b5e3bb85af86b38a5" +dependencies = [ + "cairo-rs", + "glib", + "libc", + "pango", + "pangocairo-sys", +] + +[[package]] +name = "pangocairo-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3c8ff676a37e7a72ec1d5fc029f91c407278083d2752784ff9f5188c108833" +dependencies = [ + "cairo-sys-rs", + "glib-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -3838,6 +3904,17 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rqrr" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" +dependencies = [ + "g2p", + "image", + "lru 0.18.5", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3876,9 +3953,11 @@ dependencies = [ "log", "minreq", "once_cell", + "pangocairo", "rand", "rayon", "rfd", + "rqrr", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index c6f63f8..539be70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/RustCastLabs/rustcast" [dependencies] arboard = "3.6.1" # xdg-desktop-portal ScreenCast (Wayland window/monitor recording) -ashpd = { version = "0.13.13", default-features = false, features = ["tokio", "screencast"] } +ashpd = { version = "0.13.13", default-features = false, features = ["tokio", "screencast", "screenshot"] } crossbeam-channel = "0.5.15" dirs = "5.0" emojis = "0.8.0" @@ -22,6 +22,8 @@ log = "0.4.29" minreq = { version = "2.14.1", features = ["https"] } once_cell = "1.21.3" rand = "0.9.2" +# QR / barcode reading in the screenshot editor (pure Rust, tiny) +rqrr = "0.11" rayon = "1.11.0" rfd = "0.17.2" serde = { version = "1.0.228", features = ["derive"] } @@ -38,6 +40,9 @@ x11rb = { version = "0.13.2", features = ["xtest", "randr", "composite", "shm", # tray-icon on Linux drives a GTK event loop [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" +# Text rendering (font fallback, any script, emoji) in the screenshot editor; +# links the pango-cairo library GTK already loads. +pangocairo = "0.18" [dev-dependencies] tempfile = "3.23.0" diff --git a/FEATURES.md b/FEATURES.md index dee4862..4b34d45 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -10,3 +10,9 @@ 1. Jev — a plain-words command operator (`jev open downloads`, `jev record firefox`, …) 1. Screen recorder with window lock, aspect lock, minimize-proof recording and adding extra windows into a locked recording (`Super+Shift+R`) +1. Screenshot studio (`Super+Shift+S`): frozen-screen region / window / full-screen + capture with a pixel magnifier, 12 annotation tools (arrow, line, rectangle, + ellipse, pen, highlighter, text, numbered steps, pixelate/blur/solid censor, + spotlight, colour picker, select & move), undo/redo, copy / save / save as, + pin to screen, beautify, delayed capture, quick capture, annotate existing images +1. Copy text from screen — OCR (`Super+Shift+T`) with translate, search and QR codes diff --git a/README.md b/README.md index 2c6ff97..9edf08c 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,43 @@ Rust-powered productivity launcher, plus these new features: a large preview with details, All / Text / Images filters (`←` `→`), typing to search, `↑` `↓` to browse, `Enter` to copy, `Ctrl+1…9` for quick picks. History is **persisted to disk** under `~/.local/share/rustcast/clipboard` and survives restarts. -2. **Screenshot thumbnail with real drag-and-drop** — take a region screenshot with - `Super+Shift+S` (or use PrintScreen). A thumbnail pops up in the **bottom-left - corner**; click it to copy the image, or **drag it straight into any application** - (browser, chat, file manager) as a PNG file via the XDND protocol. - -3. **Jev, the command operator** — type `jev` and say what you want in plain words. +2. **Screenshots with a built-in editor** — `Super+Shift+S` freezes the screen. + Drag to select an area or click a window (`F` for the whole screen); a magnifier + shows exact pixels and colours. Then annotate in place: + + | Key | Tool | Key | Tool | + |---|---|---|---| + | `A` | Arrow | `T` | Text (click again to re-edit) | + | `L` | Line | `N` | Numbered steps 1, 2, 3… | + | `R` | Rectangle (outline / filled) | `B` | Censor: pixelate · blur · solid | + | `O` | Ellipse | `H` | Spotlight (dim everything else) | + | `P` | Pen | `I` | Colour picker (copies `#RRGGBB`) | + | `M` | Highlighter | `V` | Select, move, recolour, delete | + + `1`–`8` pick a colour, `Shift` draws straight lines / squares, arrow keys nudge + the selection, `Ctrl+Z` / `Ctrl+Shift+Z` undo / redo. Finish with + `Enter` / `Ctrl+C` (copy), `Ctrl+S` (save), `Ctrl+Shift+S` (save as), + `Ctrl+P` (**pin** it on top of all windows), `Ctrl+T` (**copy the text**, OCR) + or `Ctrl+B` (**beautify**: gradient backdrop, padding, rounded corners, shadow). + + Every capture then pops up as a thumbnail in the **bottom-left corner** — + **drag it straight into any application**, double-click to annotate it again, + or right-click for Copy / Pin / Copy Text / Show in Folder / Delete. + Captures also land in the clipboard history. + +3. **Copy text from anywhere (OCR)** — `Super+Shift+T`, select the text, done: it + is on your clipboard (and in clipboard history) and shown in a small window + where you can fix it, **translate** it or search it. QR codes in the selection + are decoded too. Works on dark themes and terminals, any Tesseract language + (`ocr_languages = "eng+hin"`). Built for low-end machines: the engine runs only + for the fraction of a second it needs (~40 MB, then fully released), the crop is + piped in as compact grayscale — nothing stays in memory. + + More capture modes in the launcher: *Capture Window*, *Capture Full Screen*, + *Quick Capture* (copy instantly), *Capture Area in 3 / 5 / 10 Seconds*, + *Open Screenshots Folder*. + +4. **Jev, the command operator** — type `jev` and say what you want in plain words. Jev turns it into actions you run with Enter: | You type | Jev does | @@ -32,7 +63,7 @@ Rust-powered productivity launcher, plus these new features: | `jev open downloads and firefox then show desktop` | several steps → a "Run all" row | Type just `jev` for examples. -4. **Screen recorder** (`Super+Shift+R`) — opens a recorder view laid out for the +5. **Screen recorder** (`Super+Shift+R`) — opens a recorder view laid out for the job: screen cards, a grid of window cards, switches for the options, and — while recording — a live banner with the timer and a big Stop button: - **Lock onto a window**: the recording follows that one app. Windows dragged @@ -109,13 +140,16 @@ cargo build --release Runtime/build dependencies (Debian/Ubuntu names): ```sh -sudo apt install libgtk-3-dev libxcb1-dev libxtst-dev gnome-screenshot \ +sudo apt install libgtk-3-dev libxcb1-dev libxtst-dev tesseract-ocr \ ffmpeg gstreamer1.0-tools gstreamer1.0-pipewire libnotify-bin ``` - `libgtk-3-dev` / `ayatana-appindicator` — tray icon - `libxtst` — paste injection (XTEST) -- `gnome-screenshot` — region capture for the screenshot feature +- `tesseract-ocr` — text recognition (add `tesseract-ocr-` for more + languages); optional `translate-shell` translates OCR text in place +- `xdg-desktop-portal` — screen capture on Wayland (fallbacks: `grim`, + `gnome-screenshot`, `spectacle`); on X11 the screen is read directly - `ffmpeg` — video encoding for the screen recorder - `gstreamer1.0-pipewire` — Wayland (portal) recordings - X11/XCB — windowing, EWMH tiling, XDND drag source (via the pure-Rust `x11rb`) @@ -127,6 +161,7 @@ sudo apt install libgtk-3-dev libxcb1-dev libxtst-dev gnome-screenshot \ | Toggle launcher | `Alt+Space` | | Clipboard history | `Super+Shift+C` | | Screenshot capture | `Super+Shift+S` | +| Copy text from screen (OCR) | `Super+Shift+T` | | Screen recorder (again to stop) | `Super+Shift+R` | All are configurable in `~/.config/rustcast/config.toml`. @@ -138,8 +173,19 @@ the same schema as upstream RustCast, with these added fields: ```toml screenshot_hotkey = "SUPER+SHIFT+S" +ocr_hotkey = "SUPER+SHIFT+T" recorder_hotkey = "SUPER+SHIFT+R" +[screenshot] +save_dir = "~/Pictures/Screenshots" +format = "png" # png, jpg or webp +jpeg_quality = 90 +enter_action = "copy" # what Enter does in the overlay: copy or save +show_magnifier = true +window_snap = true # click a window to capture it (X11) +ocr_languages = "eng" # Tesseract codes, e.g. "eng+hin+deu" +translate_to = "" # empty = system language + [recorder] fps = 30 aspect_lock = true # fit every frame into output_width × output_height @@ -182,6 +228,13 @@ minimized and other-workspace windows keep recording and are restored. RustCast can't paint itself out: the ● REC pill is not shown (stop with the hotkey) and opening the launcher mid-recording will appear in the video. - App launching uses `.desktop` entries (`gio launch`) and `xdg-open`. +- **Screenshots on Wayland** are taken through the desktop portal; clicking a + window to capture it needs X11 (on Wayland, drag around the window instead). + The overlay covers the monitor under the pointer. + +Any capture mode can be bound to a key of your own (e.g. Print) by running +`rustcast rustcast://capture/` with `` = `area`, `window`, +`fullscreen`, `quick` or `ocr`. ## Brand diff --git a/src/app.rs b/src/app.rs index 2156f62..5b8b055 100644 --- a/src/app.rs +++ b/src/app.rs @@ -236,6 +236,9 @@ pub enum FileDialogAction { pub enum ResetField { ToggleHotkey, ClipboardHotkey, + ScreenshotHotkey, + OcrHotkey, + OcrLanguages, Placeholder, SearchUrl, DebounceDelay, @@ -381,6 +384,9 @@ pub enum SetConfigFields { ToDefault, ToggleHotkey(String), ClipboardHotkey(String), + ScreenshotHotkey(String), + OcrHotkey(String), + OcrLanguages(String), PlaceHolder(String), SearchUrl(String), ClipboardHistory(bool), diff --git a/src/app/apps.rs b/src/app/apps.rs index 56038f1..708b505 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -337,6 +337,80 @@ impl App { ] } + /// Screenshot, annotation and OCR commands. + pub fn capture_apps() -> Vec { + let icons = icns_data_to_handle(ICNS_ICON.to_vec()); + let actions: &[(&str, &str, &str, u64)] = &[ + ("Capture Area", "screenshot capture area region", "area", 0), + ("Capture Window", "screenshot capture window", "window", 0), + ( + "Capture Full Screen", + "screenshot capture full screen", + "fullscreen", + 0, + ), + ( + "Quick Capture (copy instantly)", + "screenshot quick capture copy", + "quick", + 0, + ), + ( + "Copy Text from Screen (OCR)", + "ocr copy text from screen extract read scan", + "ocr", + 0, + ), + ( + "Capture Area in 3 Seconds", + "screenshot timer delay 3 seconds", + "area", + 3, + ), + ( + "Capture Area in 5 Seconds", + "screenshot timer delay 5 seconds", + "area", + 5, + ), + ( + "Capture Area in 10 Seconds", + "screenshot timer delay 10 seconds", + "area", + 10, + ), + ]; + let mut apps: Vec = actions + .iter() + .map(|(name, search, mode, delay)| App { + ranking: 0, + open_command: AppCommand::Function(Function::Capture { + mode: mode.to_string(), + delay: *delay, + }), + desc: "Screenshot".to_string(), + icons: icons.clone(), + display_name: name.to_string(), + search_name: search.to_string(), + }) + .collect(); + apps.push(App { + ranking: 0, + open_command: AppCommand::Function(Function::OpenRawUrl( + crate::snap::load_config() + .screenshot + .save_dir() + .to_string_lossy() + .to_string(), + )), + desc: "Screenshot".to_string(), + icons, + display_name: "Open Screenshots Folder".to_string(), + search_name: "screenshots folder open".to_string(), + }); + apps + } + /// Window tiling actions (12 positions) pub fn window_apps() -> Vec { use crate::platform::window::TilePosition; diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index fe65379..e73e084 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -214,6 +214,62 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat theme.clone(), ); + let theme_clone = theme.clone(); + let shot_hotkey = settings_row_with_reset( + settings_item_column([ + settings_hint_text(theme.clone(), "Screenshot hotkey"), + settings_input("Screenshot Hotkey", &config.screenshot_hotkey) + .on_input(|input| { + Message::SetConfig(SetConfigFields::ScreenshotHotkey(input.clone())) + }) + .on_submit(Message::WriteConfig(false)) + .width(Length::Fill) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) + .into(), + notice_item( + theme.clone(), + "Select an area or click a window, then annotate", + ), + ]), + ResetField::ScreenshotHotkey, + theme.clone(), + ); + + let theme_clone = theme.clone(); + let ocr_hotkey = settings_row_with_reset( + settings_item_column([ + settings_hint_text(theme.clone(), "Copy text from screen (OCR) hotkey"), + settings_input("OCR Hotkey", &config.ocr_hotkey) + .on_input(|input| Message::SetConfig(SetConfigFields::OcrHotkey(input.clone()))) + .on_submit(Message::WriteConfig(false)) + .width(Length::Fill) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) + .into(), + notice_item(theme.clone(), "Select any text on screen to copy it"), + ]), + ResetField::OcrHotkey, + theme.clone(), + ); + + let theme_clone = theme.clone(); + let ocr_languages = settings_row_with_reset( + settings_item_column([ + settings_hint_text(theme.clone(), "OCR languages"), + settings_input("eng", &config.screenshot.ocr_languages) + .on_input(|input| Message::SetConfig(SetConfigFields::OcrLanguages(input.clone()))) + .on_submit(Message::WriteConfig(false)) + .width(Length::Fill) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) + .into(), + notice_item( + theme.clone(), + "Tesseract codes joined with \"+\", e.g. eng+hin (needs tesseract-ocr-hin)", + ), + ]), + ResetField::OcrLanguages, + theme.clone(), + ); + let theme_clone = theme.clone(); let placeholder_setting = settings_row_with_reset( settings_item_column([ @@ -393,6 +449,9 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat Column::from_iter([ hotkey, cb_hotkey, + shot_hotkey, + ocr_hotkey, + ocr_languages, placeholder_setting, search, debounce, diff --git a/src/app/screenshot.rs b/src/app/screenshot.rs index fa14437..6c7cd83 100644 --- a/src/app/screenshot.rs +++ b/src/app/screenshot.rs @@ -2,9 +2,9 @@ //! draggable bottom-left thumbnail (via the X11 overlay) and adds them to the //! clipboard history. //! -//! Capture is triggered by the configurable screenshot hotkey and shells out to -//! `gnome-screenshot -a` (interactive region select). The watcher additionally -//! catches screenshots taken by other tools (PrintScreen → `~/Pictures/Screenshots`). +//! Capture is triggered by the configurable screenshot hotkey and opens RustCast's +//! own capture overlay (see [`crate::snap`]). The watcher additionally catches +//! screenshots taken by other tools (PrintScreen → `~/Pictures/Screenshots`). use std::borrow::Cow; use std::collections::HashSet; @@ -19,9 +19,15 @@ use crate::app::{Editable, Message}; use crate::clipboard::ClipBoardContentType; use crate::persist::screenshots_dir; +/// Capture modes accepted by `rustcast://capture/` and the launcher. +pub const CAPTURE_MODES: [&str; 5] = ["area", "window", "fullscreen", "quick", "ocr"]; + /// Directories watched for new screenshots. fn watch_dirs() -> Vec { - let mut dirs = vec![screenshots_dir()]; + let mut dirs = vec![ + screenshots_dir(), + crate::snap::load_config().screenshot.save_dir(), + ]; if let Some(home) = dirs::home_dir() { dirs.push(home.join("Pictures/Screenshots")); dirs.push(home.join("Pictures")); @@ -34,46 +40,24 @@ fn watch_dirs() -> Vec { dirs } -/// Trigger an interactive region capture. The resulting PNG lands in the -/// RustCast screenshots directory, where the watcher picks it up. +/// Open the capture overlay (region select + annotate). pub fn trigger_capture() { - std::thread::spawn(|| { - let dir = screenshots_dir(); - if std::fs::create_dir_all(&dir).is_err() { - return; - } - let ts = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let path = dir.join(format!("rustcast-{ts}.png")); - - let status = std::process::Command::new("gnome-screenshot") - .arg("-a") - .arg("-f") - .arg(&path) - .status(); - - match status { - Ok(s) if s.success() && path.exists() => { - // Watcher will display + record it. - } - _ => { - log::warn!("gnome-screenshot capture failed or was cancelled"); - let _ = std::fs::remove_file(&path); - } - } - }); + crate::snap::spawn(&["area"]); } fn is_image_file(path: &std::path::Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref(), - Some("png") | Some("jpg") | Some("jpeg") - ) + // Dot-files are in-progress writes or RustCast's own temporary grabs. + let hidden = path + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with('.')); + !hidden + && matches!( + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .as_deref(), + Some("png") | Some("jpg") | Some("jpeg") | Some("webp") + ) } fn load_image_data(path: &std::path::Path) -> Option> { @@ -115,14 +99,18 @@ pub fn watch_subscription() -> impl futures::Stream { if !is_image_file(&path) || seen.contains(&path) { continue; } + let modified = entry.metadata().and_then(|m| m.modified()).ok(); + // Give other tools a moment to finish writing the file. + let settled = modified + .and_then(|m| m.elapsed().ok()) + .is_some_and(|age| age >= Duration::from_millis(300)); + if !settled { + continue; + } seen.insert(path.clone()); // Only react to files created after startup. - let fresh = entry - .metadata() - .and_then(|m| m.modified()) - .map(|m| m >= start) - .unwrap_or(false); + let fresh = modified.is_some_and(|m| m >= start); if !fresh { continue; } diff --git a/src/app/tile.rs b/src/app/tile.rs index 7291371..f470c2e 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -397,6 +397,7 @@ pub struct Hotkeys { pub clipboard_hotkey: Shortcut, pub screenshot_hotkey: Shortcut, pub recorder_hotkey: Shortcut, + pub ocr_hotkey: Shortcut, pub shells: HashMap, } @@ -407,12 +408,13 @@ impl Hotkeys { self.clipboard_hotkey.clone(), self.screenshot_hotkey.clone(), self.recorder_hotkey, + self.ocr_hotkey, ]; a.extend(self.shell_hotkeys()); a } - /// Only the user-defined shell-command hotkeys. On GNOME the four core + /// Only the user-defined shell-command hotkeys. On GNOME the core /// hotkeys are handled by the gsettings backend, so the in-process X11 /// grab registers just these to avoid double-firing. pub fn shell_hotkeys(&self) -> Vec { diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index bf0bcfd..c5b99e0 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -53,6 +53,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { options.extend(App::basic_apps()); info!("Loaded basic apps / default apps"); options.extend(App::window_apps()); + options.extend(App::capture_apps()); info!("Loaded window tiling apps"); options.par_sort_by_key(|x| x.display_name.len()); let options = AppIndex::from_apps(options); diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 5c95ec9..ecab987 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -169,6 +169,22 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { "clipboard" => Task::done(Message::KeyPressed(tile.hotkeys.clipboard_hotkey)), "screenshot" => Task::done(Message::KeyPressed(tile.hotkeys.screenshot_hotkey)), "recorder" => Task::done(Message::KeyPressed(tile.hotkeys.recorder_hotkey)), + "ocr" => Task::done(Message::KeyPressed(tile.hotkeys.ocr_hotkey)), + // Capture modes (`rustcast rustcast://capture/`), e.g. + // bound to custom keys or the Print key. + "capture" => { + let mode = url.path().trim_matches('/'); + let mode = if mode.is_empty() { "area" } else { mode }; + if crate::app::screenshot::CAPTURE_MODES.contains(&mode) { + crate::snap::spawn(&[mode]); + } + Task::none() + } + // Results handed over by a screenshot/OCR subprocess. + "snap-copy" => { + crate::snap::handle_copy_url(&url); + Task::none() + } "recorder-stop" => Task::done(Message::RecorderStop), "recorder-add" => Task::done(Message::OpenRecorderPage), @@ -218,6 +234,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { &tile.config.clipboard_hotkey, &tile.config.screenshot_hotkey, &tile.config.recorder_hotkey, + &tile.config.ocr_hotkey, ); } tile.hotkeys.shell_hotkeys() @@ -499,6 +516,10 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { tile.hotkeys.recorder_hotkey = hotkey } + if let Ok(hotkey) = Shortcut::parse(&new_config.ocr_hotkey) { + tile.hotkeys.ocr_hotkey = hotkey + } + let mut shell_map = HashMap::new(); for shell in &new_config.shells { @@ -546,6 +567,11 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { return Task::none(); } + if shortcut == tile.hotkeys.ocr_hotkey { + crate::snap::spawn(&["ocr"]); + return Task::none(); + } + // Recorder hotkey: stops a running recording straight away, // otherwise opens (or closes) the recorder page. if shortcut == tile.hotkeys.recorder_hotkey { @@ -782,6 +808,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { new_options.extend(tile.config.modes.to_apps()); new_options.extend(App::basic_apps()); new_options.extend(App::window_apps()); + new_options.extend(App::capture_apps()); new_options.par_sort_by_key(|x| x.display_name.len()); tile.options = AppIndex::from_apps(new_options); @@ -1050,6 +1077,9 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { match config.clone() { SetConfigFields::ToggleHotkey(hk) => final_config.toggle_hotkey = hk, SetConfigFields::ClipboardHotkey(hk) => final_config.clipboard_hotkey = hk, + SetConfigFields::ScreenshotHotkey(hk) => final_config.screenshot_hotkey = hk, + SetConfigFields::OcrHotkey(hk) => final_config.ocr_hotkey = hk, + SetConfigFields::OcrLanguages(l) => final_config.screenshot.ocr_languages = l, SetConfigFields::ClipboardHistory(cbhist) => final_config.cbhist = cbhist, SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); @@ -1218,6 +1248,13 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { ResetField::ClipboardHotkey => { tile.config.clipboard_hotkey = default.clipboard_hotkey } + ResetField::ScreenshotHotkey => { + tile.config.screenshot_hotkey = default.screenshot_hotkey + } + ResetField::OcrHotkey => tile.config.ocr_hotkey = default.ocr_hotkey, + ResetField::OcrLanguages => { + tile.config.screenshot.ocr_languages = default.screenshot.ocr_languages + } ResetField::Placeholder => tile.config.placeholder = default.placeholder, ResetField::SearchUrl => tile.config.search_url = default.search_url, ResetField::DebounceDelay => tile.config.debounce_delay = default.debounce_delay, @@ -1965,6 +2002,7 @@ mod tests { clipboard_hotkey: Shortcut::parse("cmd+shift+c").unwrap(), screenshot_hotkey: Shortcut::parse("super+shift+s").unwrap(), recorder_hotkey: Shortcut::parse("super+shift+r").unwrap(), + ocr_hotkey: Shortcut::parse("super+shift+t").unwrap(), shells: HashMap::new(), handle: None, }, diff --git a/src/commands.rs b/src/commands.rs index db1540e..edc6e28 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -68,6 +68,12 @@ pub enum Function { }, /// Interactive region screenshot. Screenshot, + /// A capture mode (see [`crate::app::screenshot::CAPTURE_MODES`]), + /// optionally after a countdown. + Capture { + mode: String, + delay: u64, + }, } impl Function { @@ -162,13 +168,21 @@ impl Function { }, Function::Screenshot => { - // Give the launcher a moment to disappear before selecting. + // Give the launcher a moment to disappear before the screen is frozen. thread::spawn(|| { thread::sleep(std::time::Duration::from_millis(350)); crate::app::screenshot::trigger_capture(); }); } + Function::Capture { mode, delay } => { + let (mode, delay) = (mode.clone(), delay.to_string()); + thread::spawn(move || { + thread::sleep(std::time::Duration::from_millis(350)); + crate::snap::spawn(&[&mode, "--delay", &delay]); + }); + } + // TileWindow is intercepted in the RunFunction handler which has // access to the focused window id; nothing to do here. Function::TileWindow(_) => {} diff --git a/src/config.rs b/src/config.rs index 227600c..7f5e48c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,7 +21,10 @@ pub struct Config { pub clipboard_hotkey: String, pub screenshot_hotkey: String, pub recorder_hotkey: String, + /// Select a screen region and copy the text in it (OCR). + pub ocr_hotkey: String, pub recorder: RecorderConfig, + pub screenshot: ScreenshotConfig, pub buffer_rules: Buffer, pub event_duration: u32, pub main_page: MainPage, @@ -48,7 +51,9 @@ impl Default for Config { clipboard_hotkey: "SUPER+SHIFT+C".to_string(), screenshot_hotkey: "SUPER+SHIFT+S".to_string(), recorder_hotkey: "SUPER+SHIFT+R".to_string(), + ocr_hotkey: "SUPER+SHIFT+T".to_string(), recorder: RecorderConfig::default(), + screenshot: ScreenshotConfig::default(), buffer_rules: Buffer::default(), theme: Theme::default(), start_at_login: false, @@ -319,6 +324,71 @@ impl RecorderConfig { } } +/// Screenshot / annotation / OCR settings (`[screenshot]` in the config file). +/// +/// - `save_dir`: where Ctrl+S saves (`~` is expanded) +/// - `format`: `png`, `jpg` or `webp` +/// - `enter_action`: what Enter does in the capture overlay: `copy` or `save` +/// - `ocr_languages`: Tesseract language codes joined with `+` (`eng+hin`) +/// - `translate_to`: target language for "Translate"; empty = system language +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct ScreenshotConfig { + pub save_dir: String, + pub format: String, + pub jpeg_quality: u8, + pub enter_action: String, + pub show_magnifier: bool, + pub window_snap: bool, + pub ocr_languages: String, + pub translate_to: String, +} + +impl Default for ScreenshotConfig { + fn default() -> Self { + Self { + save_dir: "~/Pictures/Screenshots".to_string(), + format: "png".to_string(), + jpeg_quality: 90, + enter_action: "copy".to_string(), + show_magnifier: true, + window_snap: true, + ocr_languages: "eng".to_string(), + translate_to: String::new(), + } + } +} + +impl ScreenshotConfig { + /// The save directory with `~` expanded. + pub fn save_dir(&self) -> std::path::PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + std::path::PathBuf::from(self.save_dir.replacen('~', &home, 1)) + } + + /// Normalised file extension for saved screenshots. + pub fn extension(&self) -> &'static str { + match self.format.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => "jpg", + "webp" => "webp", + _ => "png", + } + } + + /// Target language for translation: the configured one, else the system + /// language (`LANG=de_DE.UTF-8` → `de`), else English. + pub fn translate_target(&self) -> String { + if !self.translate_to.trim().is_empty() { + return self.translate_to.trim().to_string(); + } + std::env::var("LANG") + .ok() + .and_then(|l| l.split(['_', '.']).next().map(str::to_string)) + .filter(|l| l.len() >= 2 && l != "C" && l != "POSIX") + .unwrap_or_else(|| "en".to_string()) + } +} + /// The rules for the buffer AKA search results /// /// - clear_on_hide is whether the buffer should be cleared when the window is hidden diff --git a/src/main.rs b/src/main.rs index d923125..27f34cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod persist; mod platform; mod quit; mod recorder; +mod snap; mod styles; mod unit_conversion; mod utils; @@ -48,6 +49,13 @@ fn main() -> iced::Result { return Ok(()); } + // Screenshot overlay / annotation editor / OCR / pinned images: a + // short-lived GTK subprocess, so none of it stays resident. + if let Some(pos) = cli_args.iter().position(|a| a == "--snap") { + crate::snap::run(&cli_args[pos + 1..]); + return Ok(()); + } + // The floating "● REC" pill is another short-lived GTK subprocess. if let Some(pos) = cli_args.iter().position(|a| a == "--rec-indicator") { crate::recorder::indicator::run(&cli_args[pos + 1..]); @@ -134,6 +142,9 @@ fn main() -> iced::Result { let recorder = Shortcut::parse(&config.recorder_hotkey.to_lowercase()) .unwrap_or_else(|_| Shortcut::parse("super+shift+r").unwrap()); + let ocr = Shortcut::parse(&config.ocr_hotkey.to_lowercase()) + .unwrap_or_else(|_| Shortcut::parse("super+shift+t").unwrap()); + let mut shell_map = HashMap::new(); for shell in &config.shells { @@ -149,6 +160,7 @@ fn main() -> iced::Result { clipboard_hotkey: cbhist, screenshot_hotkey: screenshot, recorder_hotkey: recorder, + ocr_hotkey: ocr, shells: shell_map, handle: None, }; diff --git a/src/platform/linux/gnome_hotkeys.rs b/src/platform/linux/gnome_hotkeys.rs index 85c0557..bad8cee 100644 --- a/src/platform/linux/gnome_hotkeys.rs +++ b/src/platform/linux/gnome_hotkeys.rs @@ -18,7 +18,7 @@ const CUSTOM_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys.custom const BASE_PATH: &str = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings"; /// The keybindings we manage: (id, human name, `rustcast://` host). -const ENTRIES: [(&str, &str, &str); 4] = [ +const ENTRIES: [(&str, &str, &str); 5] = [ ("rustcast-toggle", "RustCast Toggle", "toggle"), ( "rustcast-clipboard", @@ -27,6 +27,7 @@ const ENTRIES: [(&str, &str, &str); 4] = [ ), ("rustcast-screenshot", "RustCast Screenshot", "screenshot"), ("rustcast-recorder", "RustCast Screen Recorder", "recorder"), + ("rustcast-ocr", "RustCast Copy Text from Screen", "ocr"), ]; /// True when running under a GNOME session with `gsettings` available, i.e. @@ -83,8 +84,15 @@ fn set(schema_path: &str, key: &str, value: &str) { /// The accelerators are RustCast hotkey strings (e.g. `"ALT+SPACE"`); empty or /// unparseable ones are skipped. `exe` is the absolute path invoked by the /// keybinding. Safe to call on every launch — it is idempotent. -pub fn register(exe: &Path, toggle: &str, clipboard: &str, screenshot: &str, recorder: &str) { - let accels = [toggle, clipboard, screenshot, recorder]; +pub fn register( + exe: &Path, + toggle: &str, + clipboard: &str, + screenshot: &str, + recorder: &str, + ocr: &str, +) { + let accels = [toggle, clipboard, screenshot, recorder, ocr]; let mut managed_paths = Vec::new(); for ((id, name, host), accel) in ENTRIES.iter().zip(accels) { diff --git a/src/platform/linux/overlay_gtk.rs b/src/platform/linux/overlay_gtk.rs index ecc7c52..5de1ec4 100644 --- a/src/platform/linux/overlay_gtk.rs +++ b/src/platform/linux/overlay_gtk.rs @@ -7,9 +7,10 @@ //! //! Interaction: //! - drag the thumbnail → drops `text/uri-list` (a `file://` path) + `image/png` -//! - right-click → copies the file path as text (for terminal programs that -//! load images by path) -//! - auto-dismisses after a timeout +//! - double-click → open it in the annotation editor +//! - right-click → Copy Image / Copy Path / Annotate / Pin / Copy Text (OCR) / +//! Show in Folder / Delete +//! - auto-dismisses after a timeout (paused while the pointer is over it) use std::path::PathBuf; use std::time::Duration; @@ -127,32 +128,126 @@ pub fn run(path: PathBuf) { evbox.connect_drag_end(move |_w, _ctx| window.close()); } - // Right-click → copy the file path as clipboard text, then dismiss shortly - // after (giving the clipboard manager time to take ownership). + // Double-click → annotate; right-click → actions menu. { let path = path.clone(); let window = window.clone(); evbox.connect_button_press_event(move |_w, ev| { + if ev.button() == 1 && ev.event_type() == gdk::EventType::DoubleButtonPress { + crate::snap::spawn(&["edit", &path.to_string_lossy()]); + window.close(); + return glib::Propagation::Stop; + } if ev.button() == 3 { - let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); - clipboard.set_text(&path.display().to_string()); - clipboard.store(); - let window = window.clone(); - glib::timeout_add_local_once(Duration::from_millis(200), move || window.close()); - glib::Propagation::Stop - } else { - glib::Propagation::Proceed + actions_menu(&window, &path, ev); + return glib::Propagation::Stop; } + glib::Propagation::Proceed }); } - // Auto-dismiss. + // Auto-dismiss, but not while the pointer rests on the thumbnail or its + // menu is open. { + let hovered = std::rc::Rc::new(std::cell::Cell::new(false)); + evbox.add_events(gdk::EventMask::ENTER_NOTIFY_MASK | gdk::EventMask::LEAVE_NOTIFY_MASK); + evbox.connect_enter_notify_event({ + let hovered = hovered.clone(); + move |_, _| { + hovered.set(true); + glib::Propagation::Proceed + } + }); + evbox.connect_leave_notify_event({ + let hovered = hovered.clone(); + move |_, ev| { + // Leaving into the popup menu is an "inferior" crossing. + if ev.detail() != gdk::NotifyType::Inferior { + hovered.set(false); + } + glib::Propagation::Proceed + } + }); let window = window.clone(); - glib::timeout_add_local_once(AUTO_DISMISS, move || window.close()); + let deadline = std::cell::Cell::new(std::time::Instant::now() + AUTO_DISMISS); + glib::timeout_add_local(Duration::from_millis(250), move || { + if hovered.get() || MENU_OPEN.with(|m| m.get()) { + deadline.set(std::time::Instant::now() + Duration::from_secs(3)); + } else if std::time::Instant::now() >= deadline.get() { + window.close(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); } window.connect_destroy(|_| gtk::main_quit()); window.show_all(); gtk::main(); } + +thread_local! { + static MENU_OPEN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Right-click menu on the thumbnail. +fn actions_menu(window: >k::Window, path: &std::path::Path, ev: &gdk::EventButton) { + let menu = gtk::Menu::new(); + let p = path.to_string_lossy().to_string(); + let add = |label: &str, close: bool, f: Box| { + let item = gtk::MenuItem::with_label(label); + let window = window.clone(); + item.connect_activate(move |_| { + f(); + if close { + // Leave the clipboard owner a moment before exiting. + let window = window.clone(); + glib::timeout_add_local_once(Duration::from_millis(200), move || window.close()); + } + }); + menu.append(&item); + }; + add("Copy Image", true, { + let path = path.to_path_buf(); + Box::new(move || crate::snap::copy_image(&path)) + }); + add("Copy Path", true, { + let p = p.clone(); + Box::new(move || { + let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); + clipboard.set_text(&p); + clipboard.store(); + }) + }); + add("Annotate…", true, { + let p = p.clone(); + Box::new(move || crate::snap::spawn(&["edit", &p])) + }); + add("Pin to Screen", true, { + let p = p.clone(); + Box::new(move || crate::snap::spawn(&["pin", &p])) + }); + add("Copy Text (OCR)", true, { + let p = p.clone(); + Box::new(move || crate::snap::spawn(&["ocr-file", &p])) + }); + add("Show in Folder", true, { + let path = path.to_path_buf(); + Box::new(move || { + if let Some(dir) = path.parent() { + crate::snap::open_url(&dir.to_string_lossy()); + } + }) + }); + menu.append(>k::SeparatorMenuItem::new()); + add("Delete", true, { + let path = path.to_path_buf(); + Box::new(move || { + let _ = std::fs::remove_file(&path); + }) + }); + MENU_OPEN.with(|m| m.set(true)); + menu.connect_deactivate(|_| MENU_OPEN.with(|m| m.set(false))); + menu.show_all(); + menu.popup_at_pointer(Some(ev)); +} diff --git a/src/platform/linux/urlscheme.rs b/src/platform/linux/urlscheme.rs index 0df7895..7e6d7c9 100644 --- a/src/platform/linux/urlscheme.rs +++ b/src/platform/linux/urlscheme.rs @@ -39,6 +39,11 @@ pub fn forward_if_running(url: &str) -> bool { false } +/// True when another RustCast instance is listening on the socket. +pub fn is_running() -> bool { + UnixStream::connect(socket_path()).is_ok() +} + /// Bind the single-instance socket and start the desktop URL handler. /// Safe to call once at startup. pub fn install() { diff --git a/src/snap/beautify.rs b/src/snap/beautify.rs new file mode 100644 index 0000000..b0c651e --- /dev/null +++ b/src/snap/beautify.rs @@ -0,0 +1,239 @@ +//! "Beautify": put a capture on a gradient backdrop with padding, rounded +//! corners and a soft drop shadow — ready for docs, slides and social posts. +//! Also home of the box blur shared with the editor's censor tool. + +use gtk::cairo::{self, Context, Format, ImageSurface, LinearGradient}; + +use super::grab::{Frame, bgra_to_rgba}; + +pub struct Style { + pub name: &'static str, + pub from: (f64, f64, f64), + pub to: (f64, f64, f64), +} + +const fn rgb(hex: u32) -> (f64, f64, f64) { + ( + ((hex >> 16) & 0xff) as f64 / 255.0, + ((hex >> 8) & 0xff) as f64 / 255.0, + (hex & 0xff) as f64 / 255.0, + ) +} + +pub const STYLES: [Style; 8] = [ + Style { + name: "Sunset", + from: rgb(0xff7e5f), + to: rgb(0xfeb47b), + }, + Style { + name: "Ocean", + from: rgb(0x2193b0), + to: rgb(0x6dd5ed), + }, + Style { + name: "Grape", + from: rgb(0x8e2de2), + to: rgb(0x4a00e0), + }, + Style { + name: "Mint", + from: rgb(0x11998e), + to: rgb(0x38ef7d), + }, + Style { + name: "Peach", + from: rgb(0xee9ca7), + to: rgb(0xffdde1), + }, + Style { + name: "Night", + from: rgb(0x232526), + to: rgb(0x414345), + }, + Style { + name: "Rust", + from: rgb(0xb7410e), + to: rgb(0xf09819), + }, + Style { + name: "Sky", + from: rgb(0x89f7fe), + to: rgb(0x66a6ff), + }, +]; + +/// Rounded-rectangle path. +pub fn rounded_rect(cr: &Context, x: f64, y: f64, w: f64, h: f64, r: f64) { + let r = r.min(w / 2.0).min(h / 2.0).max(0.0); + use std::f64::consts::{FRAC_PI_2, PI}; + cr.new_sub_path(); + cr.arc(x + w - r, y + r, r, -FRAC_PI_2, 0.0); + cr.arc(x + w - r, y + h - r, r, 0.0, FRAC_PI_2); + cr.arc(x + r, y + h - r, r, FRAC_PI_2, PI); + cr.arc(x + r, y + r, r, PI, 3.0 * FRAC_PI_2); + cr.close_path(); +} + +/// Apply style `idx` to `img`. `k` scales paddings for HiDPI captures. +pub fn apply(img: &image::RgbaImage, idx: usize, k: f64) -> image::RgbaImage { + let style = &STYLES[idx % STYLES.len()]; + let (w, h) = (f64::from(img.width()), f64::from(img.height())); + let pad = (w.min(h) * 0.08).clamp(32.0 * k, 96.0 * k).round(); + let radius = 12.0 * k; + let (ow, oh) = ((w + 2.0 * pad) as i32, (h + 2.0 * pad) as i32); + + let render = || -> Result { + let out = ImageSurface::create(Format::ARgb32, ow, oh)?; + let cr = Context::new(&out)?; + + let grad = LinearGradient::new(0.0, 0.0, f64::from(ow), f64::from(oh)); + grad.add_color_stop_rgb(0.0, style.from.0, style.from.1, style.from.2); + grad.add_color_stop_rgb(1.0, style.to.0, style.to.1, style.to.2); + cr.set_source(&grad)?; + cr.paint()?; + + // Shadow: a dark rounded rect, blurred, offset slightly downwards. + let blur = (18.0 * k) as i32; + let mut shadow = ImageSurface::create(Format::ARgb32, ow, oh)?; + { + let sc = Context::new(&shadow)?; + rounded_rect(&sc, pad, pad + 6.0 * k, w, h, radius); + sc.set_source_rgba(0.0, 0.0, 0.0, 0.45); + sc.fill()?; + } + { + let stride = shadow.stride(); + let mut data = shadow.data().map_err(|_| cairo::Error::SurfaceFinished)?; + box_blur(&mut data, ow, oh, stride, blur.max(1)); + } + cr.set_source_surface(&shadow, 0.0, 0.0)?; + cr.paint()?; + + let frame = Frame::from_rgba(img.clone()); + let stride = frame.stride(); + let src = ImageSurface::create_for_data( + frame.data, + Format::ARgb32, + frame.width, + frame.height, + stride, + )?; + rounded_rect(&cr, pad, pad, w, h, radius); + cr.clip(); + cr.set_source_surface(&src, pad, pad)?; + cr.paint()?; + drop(cr); + + out.flush(); + let mut rgba = None; + out.with_data(|d| rgba = Some(bgra_to_rgba(d, ow, oh, out.stride()))) + .map_err(|_| cairo::Error::SurfaceFinished)?; + Ok(rgba.unwrap_or_default()) + }; + render().unwrap_or_else(|_| img.clone()) +} + +/// In-place blur of a 4-channel buffer: three box passes ≈ a Gaussian, each +/// pass O(pixels) regardless of the radius (fast on slow CPUs). +pub fn box_blur(data: &mut [u8], w: i32, h: i32, stride: i32, radius: i32) { + if w <= 0 || h <= 0 || radius <= 0 { + return; + } + let (w, h, stride) = (w as usize, h as usize, stride as usize); + let r = radius as usize; + let mut line: Vec<[u32; 4]> = Vec::with_capacity(w.max(h)); + for _ in 0..3 { + // Horizontal. + for y in 0..h { + line.clear(); + line.extend((0..w).map(|x| { + let i = y * stride + x * 4; + [ + u32::from(data[i]), + u32::from(data[i + 1]), + u32::from(data[i + 2]), + u32::from(data[i + 3]), + ] + })); + blur_line(&line, r, |x, px| { + let i = y * stride + x * 4; + data[i..i + 4].copy_from_slice(&px); + }); + } + // Vertical. + for x in 0..w { + line.clear(); + line.extend((0..h).map(|y| { + let i = y * stride + x * 4; + [ + u32::from(data[i]), + u32::from(data[i + 1]), + u32::from(data[i + 2]), + u32::from(data[i + 3]), + ] + })); + blur_line(&line, r, |y, px| { + let i = y * stride + x * 4; + data[i..i + 4].copy_from_slice(&px); + }); + } + } +} + +/// Running-sum box filter over one row/column (edges clamped). +fn blur_line(src: &[[u32; 4]], r: usize, mut put: impl FnMut(usize, [u8; 4])) { + let n = src.len(); + if n == 0 { + return; + } + let window = (2 * r + 1) as u32; + let at = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; + let mut sum = [0u32; 4]; + for i in -(r as isize)..=(r as isize) { + let p = at(i); + for c in 0..4 { + sum[c] += p[c]; + } + } + for x in 0..n { + put(x, std::array::from_fn(|c| (sum[c] / window) as u8)); + let add = at(x as isize + r as isize + 1); + let sub = at(x as isize - r as isize); + for c in 0..4 { + sum[c] = sum[c] + add[c] - sub[c]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blur_keeps_flat_areas_and_softens_edges() { + let (w, h) = (20, 4); + let mut data = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 10..w { + data[(y * w + x) * 4..(y * w + x) * 4 + 4].copy_from_slice(&[255; 4]); + } + } + box_blur(&mut data, w as i32, h as i32, (w * 4) as i32, 2); + let px = |x: usize| data[x * 4]; + assert_eq!(px(0), 0); + assert_eq!(px(19), 255); + assert!(px(9) > 0 && px(9) < 255); + assert!(px(10) > 0 && px(10) < 255); + } + + #[test] + fn beautify_adds_padding() { + let img = image::RgbaImage::from_pixel(200, 100, image::Rgba([10, 20, 30, 255])); + let out = apply(&img, 1, 1.0); + assert!(out.width() > 200 && out.height() > 100); + // The capture itself sits in the middle, untouched. + let c = out.get_pixel(out.width() / 2, out.height() / 2).0; + assert_eq!(c, [10, 20, 30, 255]); + } +} diff --git a/src/snap/editor.rs b/src/snap/editor.rs new file mode 100644 index 0000000..522e349 --- /dev/null +++ b/src/snap/editor.rs @@ -0,0 +1,2561 @@ +//! The capture overlay and annotation editor. +//! +//! One GTK window, one `DrawingArea`, everything drawn with cairo: the frozen +//! screen, the dimmed surroundings, the selection, annotations and the toolbars. +//! There is a single full-resolution copy of the screen (the cairo surface the +//! frame was grabbed into); everything else is computed on the fly. +//! +//! All geometry is kept in *image pixels* (the real resolution of the capture) +//! and converted from window coordinates at the edges, so HiDPI and fractional +//! scaling export at full quality and annotations look identical on screen and +//! in the saved file. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk::cairo::{self, Context, Filter, Format, ImageSurface, Operator, SurfacePattern}; +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; +use image::RgbaImage; + +use super::beautify::{self, rounded_rect}; +use super::grab::{self, Frame, bgra_to_rgba}; +use crate::config::ScreenshotConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Flavor { + /// Select a region (or click a window), then annotate. + Area, + /// Like `Area`, starting with window picking. + Window, + /// Select a region, then copy/save immediately. + Quick, + /// Select a region, then read its text. + Ocr, + /// The whole monitor under the pointer, no UI. + Fullscreen, + /// Annotate an existing image. + Edit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tool { + Select, + Arrow, + Line, + Rect, + Ellipse, + Pen, + Marker, + Text, + Number, + Censor, + Spotlight, + Picker, +} + +const TOOLS: [(Tool, &str, char); 12] = [ + (Tool::Select, "Select & move", 'v'), + (Tool::Arrow, "Arrow", 'a'), + (Tool::Line, "Line", 'l'), + (Tool::Rect, "Rectangle", 'r'), + (Tool::Ellipse, "Ellipse", 'o'), + (Tool::Pen, "Pen", 'p'), + (Tool::Marker, "Highlighter", 'm'), + (Tool::Text, "Text", 't'), + (Tool::Number, "Numbered step", 'n'), + (Tool::Censor, "Censor (pixelate / blur / solid)", 'b'), + (Tool::Spotlight, "Spotlight", 'h'), + (Tool::Picker, "Color picker", 'i'), +]; + +type Rgb = (f64, f64, f64); + +const COLORS: [(Rgb, &str); 8] = [ + ((1.0, 0.231, 0.188), "Red"), + ((1.0, 0.584, 0.0), "Orange"), + ((1.0, 0.8, 0.0), "Yellow"), + ((0.204, 0.78, 0.349), "Green"), + ((0.0, 0.478, 1.0), "Blue"), + ((0.686, 0.322, 0.871), "Purple"), + ((0.11, 0.11, 0.118), "Black"), + ((1.0, 1.0, 1.0), "White"), +]; + +/// Stroke widths in logical pixels (S / M / L). +const SIZES: [f64; 3] = [2.5, 4.5, 8.0]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CensorMode { + Pixelate, + Blur, + Solid, +} + +impl CensorMode { + fn next(self) -> Self { + match self { + CensorMode::Pixelate => CensorMode::Blur, + CensorMode::Blur => CensorMode::Solid, + CensorMode::Solid => CensorMode::Pixelate, + } + } + fn label(self) -> &'static str { + match self { + CensorMode::Pixelate => "Pixelate", + CensorMode::Blur => "Blur", + CensorMode::Solid => "Solid", + } + } +} + +type P = (f64, f64); + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +struct R { + x: f64, + y: f64, + w: f64, + h: f64, +} + +impl R { + fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + R { x, y, w, h } + } + fn from_pts(a: P, b: P) -> Self { + R::new( + a.0.min(b.0), + a.1.min(b.1), + (a.0 - b.0).abs(), + (a.1 - b.1).abs(), + ) + } + fn contains(&self, p: P) -> bool { + p.0 >= self.x && p.0 <= self.x + self.w && p.1 >= self.y && p.1 <= self.y + self.h + } + fn inflate(&self, d: f64) -> R { + R::new(self.x - d, self.y - d, self.w + 2.0 * d, self.h + 2.0 * d) + } + fn right(&self) -> f64 { + self.x + self.w + } + fn bottom(&self) -> f64 { + self.y + self.h + } + /// Clamp into `0..w` × `0..h` and snap to whole pixels. + fn clamp_to(&self, w: f64, h: f64) -> R { + let x0 = self.x.clamp(0.0, w).round(); + let y0 = self.y.clamp(0.0, h).round(); + let x1 = self.right().clamp(0.0, w).round(); + let y1 = self.bottom().clamp(0.0, h).round(); + R::new(x0, y0, x1 - x0, y1 - y0) + } +} + +#[derive(Clone)] +enum Shape { + Arrow(P, P), + Line(P, P), + Rect(R), + Ellipse(R), + Pen(Vec

), + Marker(Vec

), + Text { + at: P, + text: String, + size: f64, + extent: P, + }, + Number { + at: P, + n: u32, + }, + Censor { + r: R, + mode: CensorMode, + blurred: Option, + }, + Spotlight(R), +} + +#[derive(Clone)] +struct Ann { + shape: Shape, + color: Rgb, + /// Stroke width in image pixels. + width: f64, + fill: bool, +} + +impl Ann { + fn bounds(&self, k: f64) -> R { + let pts_bounds = |pts: &[P]| { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in pts { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + } + R::new(x0, y0, x1 - x0, y1 - y0) + }; + match &self.shape { + Shape::Arrow(a, b) | Shape::Line(a, b) => R::from_pts(*a, *b), + Shape::Rect(r) | Shape::Ellipse(r) | Shape::Spotlight(r) => *r, + Shape::Censor { r, .. } => *r, + Shape::Pen(p) | Shape::Marker(p) => pts_bounds(p), + Shape::Text { at, extent, .. } => R::new(at.0, at.1, extent.0, extent.1), + Shape::Number { at, .. } => { + let r = number_radius(self.width, k); + R::new(at.0 - r, at.1 - r, 2.0 * r, 2.0 * r) + } + } + } + + fn hit(&self, p: P, k: f64) -> bool { + let tol = self.width / 2.0 + 6.0 * k; + let near_seg = |a: P, b: P| dist_to_segment(p, a, b) <= tol; + match &self.shape { + Shape::Arrow(a, b) | Shape::Line(a, b) => near_seg(*a, *b), + Shape::Pen(pts) | Shape::Marker(pts) => { + let tol = if matches!(self.shape, Shape::Marker(_)) { + tol + self.width * 1.5 + } else { + tol + }; + pts.windows(2) + .any(|w| dist_to_segment(p, w[0], w[1]) <= tol) + || pts.first().is_some_and(|q| dist(p, *q) <= tol) + } + Shape::Rect(r) | Shape::Ellipse(r) if !self.fill => { + r.inflate(tol).contains(p) && !r.inflate(-tol).contains(p) + } + _ => self.bounds(k).inflate(4.0 * k).contains(p), + } + } + + fn translate(&mut self, dx: f64, dy: f64) { + let mv = |p: &mut P| { + p.0 += dx; + p.1 += dy; + }; + match &mut self.shape { + Shape::Arrow(a, b) | Shape::Line(a, b) => { + mv(a); + mv(b); + } + Shape::Rect(r) | Shape::Ellipse(r) | Shape::Spotlight(r) => { + r.x += dx; + r.y += dy; + } + Shape::Censor { r, blurred, .. } => { + r.x += dx; + r.y += dy; + *blurred = None; + } + Shape::Pen(pts) | Shape::Marker(pts) => pts.iter_mut().for_each(mv), + Shape::Text { at, .. } | Shape::Number { at, .. } => mv(at), + } + } + + /// Too small to keep (an accidental click with a drawing tool). + fn degenerate(&self) -> bool { + match &self.shape { + Shape::Arrow(a, b) | Shape::Line(a, b) => dist(*a, *b) < 3.0, + Shape::Rect(r) | Shape::Ellipse(r) | Shape::Spotlight(r) => r.w < 3.0 || r.h < 3.0, + Shape::Censor { r, .. } => r.w < 3.0 || r.h < 3.0, + Shape::Pen(p) | Shape::Marker(p) => p.len() < 2, + Shape::Text { text, .. } => text.trim().is_empty(), + Shape::Number { .. } => false, + } + } +} + +fn dist(a: P, b: P) -> f64 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +fn dist_to_segment(p: P, a: P, b: P) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + if len2 == 0.0 { + return dist(p, a); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0); + dist(p, (a.0 + t * dx, a.1 + t * dy)) +} + +fn number_radius(width: f64, k: f64) -> f64 { + 11.0 * k + width * 1.2 +} + +/// Snap `b` to 45° steps around `a` (Shift while drawing lines/arrows). +fn snap45(a: P, b: P) -> P { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + let step = std::f64::consts::FRAC_PI_4; + let ang = (dy.atan2(dx) / step).round() * step; + (a.0 + len * ang.cos(), a.1 + len * ang.sin()) +} + +/// Make the rect from `a` to `b` a square (Shift while drawing shapes). +fn square(a: P, b: P) -> P { + let side = (b.0 - a.0).abs().max((b.1 - a.1).abs()); + ( + a.0 + side * (b.0 - a.0).signum(), + a.1 + side * (b.1 - a.1).signum(), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Btn { + Tool(Tool), + Color(usize), + Size(usize), + Fill, + Censor, + Undo, + Redo, + Ocr, + Pin, + Beautify, + Save, + Copy, + Close, +} + +impl Btn { + fn tooltip(self, ed: &Editor) -> String { + match self { + Btn::Tool(t) => TOOLS + .iter() + .find(|(tool, ..)| *tool == t) + .map(|(_, name, key)| format!("{name} ({})", key.to_ascii_uppercase())) + .unwrap_or_default(), + Btn::Color(i) => format!("{} ({})", COLORS[i].1, i + 1), + Btn::Size(i) => ["Thin", "Medium", "Thick"][i].to_string(), + Btn::Fill => if ed.fill { "Filled" } else { "Outline" }.to_string(), + Btn::Censor => format!("Censor mode: {}", ed.censor.label()), + Btn::Undo => "Undo (Ctrl+Z)".into(), + Btn::Redo => "Redo (Ctrl+Shift+Z)".into(), + Btn::Ocr => "Copy text in selection — OCR (Ctrl+T)".into(), + Btn::Pin => "Pin to screen (Ctrl+P)".into(), + Btn::Beautify => match ed.beautify { + None => "Beautify: background & shadow (Ctrl+B)".into(), + Some(i) => format!( + "Beautify: {} — click for next style", + beautify::STYLES[i].name + ), + }, + Btn::Save => "Save (Ctrl+S · Ctrl+Shift+S: Save As)".into(), + Btn::Copy => "Copy (Ctrl+C · Enter)".into(), + Btn::Close => "Cancel (Esc)".into(), + } + } +} + +enum Drag { + None, + /// Selecting a region; `moved` turns a click into a drag. + NewSel { + start: P, + moved: bool, + }, + MoveSel { + start: P, + orig: R, + }, + Resize { + handle: usize, + orig: R, + }, + /// Drawing an annotation; `anchor` is where the drag started. + Draw { + ann: Ann, + anchor: P, + }, + MoveAnn { + idx: usize, + last: P, + }, +} + +struct TextEdit { + at: P, + text: String, + size: f64, + color: Rgb, + /// Index of the annotation being re-edited. + replacing: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Copy, + Save, + SaveAs, + Pin, + Ocr, +} + +struct Outcome { + kind: Kind, + img: RgbaImage, +} + +struct Editor { + flavor: Flavor, + cfg: ScreenshotConfig, + bg: ImageSurface, + iw: f64, + ih: f64, + /// Image pixels per window pixel, and where the image sits in the window. + s: f64, + ox: f64, + oy: f64, + /// Annotation scale: logical → image pixels. + k: f64, + sel: Option, + windows: Vec, + snap: bool, + tool: Tool, + color: Rgb, + size: usize, + fill: bool, + censor: CensorMode, + anns: Vec, + undo: Vec>, + redo: Vec>, + drag: Drag, + shift: bool, + pointer: Option

, + text: Option, + selected: Option, + counter: u32, + beautify: Option, + buttons: Vec<(R, Btn)>, + toast: Option<(String, Instant)>, + cursor: &'static str, + outcome: Option, + close: bool, +} + +const BTN: f64 = 30.0; +const BAR_PAD: f64 = 5.0; +const HANDLE: f64 = 5.0; + +impl Editor { + fn new(flavor: Flavor, cfg: ScreenshotConfig, bg: ImageSurface, k: f64) -> Self { + let (iw, ih) = (f64::from(bg.width()), f64::from(bg.height())); + Editor { + flavor, + snap: cfg.window_snap || flavor == Flavor::Window, + cfg, + bg, + iw, + ih, + s: k, + ox: 0.0, + oy: 0.0, + k, + sel: None, + windows: Vec::new(), + tool: Tool::Arrow, + color: COLORS[0].0, + size: 1, + fill: false, + censor: CensorMode::Pixelate, + anns: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + drag: Drag::None, + shift: false, + pointer: None, + text: None, + selected: None, + counter: 0, + beautify: None, + buttons: Vec::new(), + toast: None, + cursor: "crosshair", + outcome: None, + close: false, + } + } + + fn to_img(&self, p: P) -> P { + ((p.0 - self.ox) * self.s, (p.1 - self.oy) * self.s) + } + + fn to_win(&self, p: P) -> P { + (p.0 / self.s + self.ox, p.1 / self.s + self.oy) + } + + fn rect_to_win(&self, r: R) -> R { + let (x, y) = self.to_win((r.x, r.y)); + R::new(x, y, r.w / self.s, r.h / self.s) + } + + fn stroke(&self) -> f64 { + SIZES[self.size] * self.k + } + + fn whole(&self) -> R { + R::new(0.0, 0.0, self.iw, self.ih) + } + + fn picking(&self) -> bool { + self.sel.is_none() || matches!(self.drag, Drag::NewSel { .. }) + } + + fn show_toolbars(&self) -> bool { + self.sel.is_some() + && !matches!(self.drag, Drag::NewSel { .. }) + && !matches!(self.flavor, Flavor::Quick | Flavor::Ocr) + } + + fn toast(&mut self, msg: impl Into) { + self.toast = Some((msg.into(), Instant::now())); + } + + fn window_at(&self, p: P) -> Option { + if !self.snap { + return None; + } + self.windows.iter().find(|w| w.contains(p)).copied() + } + + fn push_undo(&mut self) { + self.undo.push(self.anns.clone()); + if self.undo.len() > 100 { + self.undo.remove(0); + } + self.redo.clear(); + } + + fn do_undo(&mut self) { + if let Some(prev) = self.undo.pop() { + self.redo.push(std::mem::replace(&mut self.anns, prev)); + self.selected = None; + } + } + + fn do_redo(&mut self) { + if let Some(next) = self.redo.pop() { + self.undo.push(std::mem::replace(&mut self.anns, next)); + self.selected = None; + } + } + + fn pixel(&self, p: P) -> Option<(u8, u8, u8)> { + let (x, y) = (p.0.floor() as i32, p.1.floor() as i32); + if x < 0 || y < 0 || x >= self.bg.width() || y >= self.bg.height() { + return None; + } + let mut out = None; + let stride = self.bg.stride(); + let _ = self.bg.with_data(|d| { + let i = (y * stride + x * 4) as usize; + out = Some((d[i + 2], d[i + 1], d[i])); + }); + out + } + + // ── Selection handles ──────────────────────────────────────────────── + + /// Handle positions (window px): corners and edge midpoints, clockwise from top-left. + fn handles(&self) -> Vec

{ + let Some(sel) = self.sel else { + return Vec::new(); + }; + let r = self.rect_to_win(sel); + let (x0, y0, x1, y1) = (r.x, r.y, r.right(), r.bottom()); + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + vec![ + (x0, y0), + (mx, y0), + (x1, y0), + (x1, my), + (x1, y1), + (mx, y1), + (x0, y1), + (x0, my), + ] + } + + fn handle_at(&self, w: P) -> Option { + self.handles() + .iter() + .position(|h| (h.0 - w.0).abs() <= HANDLE + 4.0 && (h.1 - w.1).abs() <= HANDLE + 4.0) + } + + fn resize(&self, handle: usize, orig: R, p: P) -> R { + let (mut x0, mut y0, mut x1, mut y1) = (orig.x, orig.y, orig.right(), orig.bottom()); + match handle { + 0 => (x0, y0) = p, + 1 => y0 = p.1, + 2 => (x1, y0) = p, + 3 => x1 = p.0, + 4 => (x1, y1) = p, + 5 => y1 = p.1, + 6 => (x0, y1) = p, + _ => x0 = p.0, + } + R::from_pts((x0, y0), (x1, y1)).clamp_to(self.iw, self.ih) + } + + // ── Input ──────────────────────────────────────────────────────────── + + fn press(&mut self, w: P, button: u32) { + if let Some(btn) = self + .buttons + .iter() + .find(|(r, _)| r.contains(w)) + .map(|(_, b)| *b) + { + self.click_button(btn); + return; + } + if button == 3 { + if !matches!(self.drag, Drag::None) { + self.drag = Drag::None; + } else if self.flavor != Flavor::Edit && self.anns.is_empty() && self.text.is_none() { + self.sel = None; + } + return; + } + if button != 1 { + return; + } + if self.text.is_some() { + self.commit_text(); + return; + } + let p = self.to_img(w); + if self.sel.is_none() || matches!(self.flavor, Flavor::Quick | Flavor::Ocr) { + self.drag = Drag::NewSel { + start: p, + moved: false, + }; + return; + } + if let Some(handle) = self.handle_at(w) { + self.drag = Drag::Resize { + handle, + orig: self.sel.unwrap_or_default(), + }; + return; + } + let sel = self.sel.unwrap_or_default(); + let (k, width, color, fill) = (self.k, self.stroke(), self.color, self.fill); + let new = |shape| Drag::Draw { + ann: Ann { + shape, + color, + width, + fill, + }, + anchor: p, + }; + match self.tool { + Tool::Select => { + if let Some(idx) = self.anns.iter().rposition(|a| a.hit(p, k)) { + self.push_undo(); + self.selected = Some(idx); + self.drag = Drag::MoveAnn { idx, last: p }; + } else if sel.contains(p) { + self.selected = None; + self.drag = Drag::MoveSel { + start: p, + orig: sel, + }; + } else { + self.selected = None; + self.drag = Drag::NewSel { + start: p, + moved: false, + }; + } + } + Tool::Picker => { + if let Some((r, g, b)) = self.pixel(p) { + self.color = ( + f64::from(r) / 255.0, + f64::from(g) / 255.0, + f64::from(b) / 255.0, + ); + let hex = format!("#{r:02X}{g:02X}{b:02X}"); + super::copy_text(&hex); + self.toast(format!("Copied {hex}")); + } + } + Tool::Text => { + let hit = self + .anns + .iter() + .rposition(|a| matches!(a.shape, Shape::Text { .. }) && a.hit(p, k)); + if let Some(idx) = hit + && let Shape::Text { at, text, size, .. } = &self.anns[idx].shape + { + self.text = Some(TextEdit { + at: *at, + text: text.clone(), + size: *size, + color: self.anns[idx].color, + replacing: Some(idx), + }); + } else { + self.text = Some(TextEdit { + at: p, + text: String::new(), + size: (14.0 + 6.0 * self.size as f64) * k, + color, + replacing: None, + }); + } + } + Tool::Number => { + self.push_undo(); + self.counter += 1; + self.anns.push(Ann { + shape: Shape::Number { + at: p, + n: self.counter, + }, + color, + width, + fill, + }); + } + Tool::Arrow => self.drag = new(Shape::Arrow(p, p)), + Tool::Line => self.drag = new(Shape::Line(p, p)), + Tool::Rect => self.drag = new(Shape::Rect(R::new(p.0, p.1, 0.0, 0.0))), + Tool::Ellipse => self.drag = new(Shape::Ellipse(R::new(p.0, p.1, 0.0, 0.0))), + Tool::Pen => self.drag = new(Shape::Pen(vec![p])), + Tool::Marker => self.drag = new(Shape::Marker(vec![p])), + Tool::Censor => { + self.drag = new(Shape::Censor { + r: R::new(p.0, p.1, 0.0, 0.0), + mode: self.censor, + blurred: None, + }) + } + Tool::Spotlight => self.drag = new(Shape::Spotlight(R::new(p.0, p.1, 0.0, 0.0))), + } + } + + fn motion(&mut self, w: P) { + self.pointer = Some(w); + let p = self.to_img(w); + let shift = self.shift; + let (iw, ih) = (self.iw, self.ih); + match &mut self.drag { + Drag::None => {} + Drag::NewSel { start, moved } => { + let start = *start; + if !*moved && dist(start, p) / self.s > 3.0 { + *moved = true; + } + if *moved { + let end = if shift { square(start, p) } else { p }; + self.sel = Some(R::from_pts(start, end).clamp_to(iw, ih)); + } + } + Drag::MoveSel { start, orig } => { + let (dx, dy) = (p.0 - start.0, p.1 - start.1); + let x = (orig.x + dx).clamp(0.0, iw - orig.w).round(); + let y = (orig.y + dy).clamp(0.0, ih - orig.h).round(); + self.sel = Some(R::new(x, y, orig.w, orig.h)); + } + Drag::Resize { handle, orig } => { + let (handle, orig) = (*handle, *orig); + self.sel = Some(self.resize(handle, orig, p)); + } + Drag::Draw { ann, anchor } => { + let a = *anchor; + match &mut ann.shape { + Shape::Arrow(_, b) | Shape::Line(_, b) => { + *b = if shift { snap45(a, p) } else { p } + } + Shape::Rect(r) + | Shape::Ellipse(r) + | Shape::Spotlight(r) + | Shape::Censor { r, .. } => { + *r = R::from_pts(a, if shift { square(a, p) } else { p }) + } + Shape::Pen(pts) | Shape::Marker(pts) => { + if pts.last().is_none_or(|l| dist(*l, p) >= 1.5) { + pts.push(p); + } + } + _ => {} + } + } + Drag::MoveAnn { idx, last } => { + let (dx, dy) = (p.0 - last.0, p.1 - last.1); + *last = p; + let idx = *idx; + if let Some(a) = self.anns.get_mut(idx) { + a.translate(dx, dy); + } + } + } + } + + /// Returns true when the capture should finish right away (quick/OCR). + fn release(&mut self, w: P) -> bool { + let p = self.to_img(w); + match std::mem::replace(&mut self.drag, Drag::None) { + Drag::NewSel { moved, .. } => { + let tiny = self.sel.is_none_or(|s| s.w < 2.0 || s.h < 2.0); + if !moved || tiny { + // A click: the window under the pointer, else the whole screen. + let target = self.window_at(p).unwrap_or(self.whole()); + self.sel = Some(target.clamp_to(self.iw, self.ih)); + } + matches!(self.flavor, Flavor::Quick | Flavor::Ocr) + } + Drag::Draw { mut ann, .. } => { + if !ann.degenerate() { + if let Shape::Censor { + r, + mode: CensorMode::Blur, + blurred, + } = &mut ann.shape + { + *blurred = blur_patch(&self.bg, *r, self.k); + } + self.push_undo(); + self.anns.push(ann); + } + false + } + Drag::MoveAnn { idx, .. } => { + let k = self.k; + if let Some(Ann { + shape: + Shape::Censor { + r, + mode: CensorMode::Blur, + blurred, + }, + .. + }) = self.anns.get_mut(idx) + { + *blurred = blur_patch(&self.bg, *r, k); + } + false + } + _ => false, + } + } + + fn click_button(&mut self, btn: Btn) { + match btn { + Btn::Tool(t) => self.set_tool(t), + Btn::Color(i) => self.set_color(i), + Btn::Size(i) => self.size = i, + Btn::Fill => self.fill = !self.fill, + Btn::Censor => self.censor = self.censor.next(), + Btn::Undo => self.do_undo(), + Btn::Redo => self.do_redo(), + Btn::Ocr => self.finish(Kind::Ocr), + Btn::Pin => self.finish(Kind::Pin), + Btn::Beautify => self.cycle_beautify(), + Btn::Save => self.finish(Kind::Save), + Btn::Copy => self.finish(Kind::Copy), + Btn::Close => self.close = true, + } + } + + fn set_tool(&mut self, t: Tool) { + self.commit_text(); + self.tool = t; + self.selected = None; + } + + fn set_color(&mut self, i: usize) { + self.color = COLORS[i].0; + if let Some(t) = &mut self.text { + t.color = self.color; + } else if let Some(a) = self.selected.and_then(|i| self.anns.get(i)).cloned() { + // Recolour the selected annotation. + self.push_undo(); + if let Some(idx) = self.selected { + self.anns[idx] = Ann { + color: self.color, + ..a + }; + } + } + } + + fn cycle_beautify(&mut self) { + self.beautify = match self.beautify { + None => Some(0), + Some(i) if i + 1 < beautify::STYLES.len() => Some(i + 1), + Some(_) => None, + }; + match self.beautify { + Some(i) => self.toast(format!( + "Beautify: {} ({}/{})", + beautify::STYLES[i].name, + i + 1, + beautify::STYLES.len() + )), + None => self.toast("Beautify off"), + } + } + + fn commit_text(&mut self) { + let Some(edit) = self.text.take() else { + return; + }; + let extent = measure_text(&edit.text, edit.size); + let ann = Ann { + shape: Shape::Text { + at: edit.at, + text: edit.text, + size: edit.size, + extent, + }, + color: edit.color, + width: self.stroke(), + fill: false, + }; + self.push_undo(); + match edit.replacing { + Some(idx) if ann.degenerate() => { + self.anns.remove(idx); + } + Some(idx) => self.anns[idx] = ann, + None if !ann.degenerate() => self.anns.push(ann), + None => { + self.undo.pop(); + } + } + } + + /// Build the output image and close the overlay. + fn finish(&mut self, kind: Kind) { + self.commit_text(); + let Some(sel) = self.sel else { + return; + }; + // OCR reads the screen itself; annotations would only confuse it. + let img = if kind == Kind::Ocr { + crop_surface(&self.bg, sel) + } else { + self.export(sel).map(|img| match self.beautify { + Some(style) => beautify::apply(&img, style, self.k), + None => img, + }) + }; + if let Some(img) = img { + self.outcome = Some(Outcome { kind, img }); + } + self.close = true; + } + + /// Render the selection with all annotations at full resolution. + fn export(&self, sel: R) -> Option { + let (w, h) = (sel.w as i32, sel.h as i32); + if w <= 0 || h <= 0 { + return None; + } + let out = ImageSurface::create(Format::ARgb32, w, h).ok()?; + { + let cr = Context::new(&out).ok()?; + cr.translate(-sel.x, -sel.y); + cr.set_source_surface(&self.bg, 0.0, 0.0).ok()?; + cr.paint().ok()?; + self.render_annotations(&cr, sel, false); + } + out.flush(); + let mut img = None; + out.with_data(|d| img = Some(bgra_to_rgba(d, w, h, out.stride()))) + .ok()?; + img + } + + fn key(&mut self, ev: &gdk::EventKey) -> bool { + use gdk::keys::constants as k; + let key = ev.keyval(); + let ctrl = ev.state().contains(gdk::ModifierType::CONTROL_MASK); + let shift = ev.state().contains(gdk::ModifierType::SHIFT_MASK); + + if let Some(edit) = &mut self.text { + match key { + k::Escape => { + self.text = None; + } + k::Return | k::KP_Enter if shift => edit.text.push('\n'), + k::Return | k::KP_Enter => self.commit_text(), + k::BackSpace => { + edit.text.pop(); + } + _ if ctrl && key.to_lower() == k::v => { + let cb = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); + if let Some(t) = cb.wait_for_text() { + edit.text.push_str(&t); + } + } + _ if !ctrl => { + if let Some(c) = key.to_unicode().filter(|c| !c.is_control()) { + edit.text.push(c); + } + } + _ => {} + } + return true; + } + + match key { + k::Escape => { + // Esc drops an annotation being drawn; otherwise it cancels. + match std::mem::replace(&mut self.drag, Drag::None) { + Drag::Draw { .. } => {} + // Put a dragged annotation back where it was. + Drag::MoveAnn { .. } => self.do_undo(), + _ => self.close = true, + } + } + k::Return | k::KP_Enter => { + if self.sel.is_some() { + if self.cfg.enter_action.eq_ignore_ascii_case("save") { + self.finish(Kind::Save); + } else { + self.finish(Kind::Copy); + } + } + } + k::Delete | k::BackSpace => { + let idx = self.selected.take().or(self.anns.len().checked_sub(1)); + if let Some(idx) = idx.filter(|i| *i < self.anns.len()) { + self.push_undo(); + self.anns.remove(idx); + } + } + k::Tab => { + self.snap = !self.snap; + self.toast(if self.snap { + "Window snapping on" + } else { + "Window snapping off" + }); + } + k::Left | k::Right | k::Up | k::Down => { + if let Some(sel) = self.sel { + let step = if shift { 10.0 } else { 1.0 }; + let (dx, dy) = match key { + k::Left => (-step, 0.0), + k::Right => (step, 0.0), + k::Up => (0.0, -step), + _ => (0.0, step), + }; + let x = (sel.x + dx).clamp(0.0, self.iw - sel.w); + let y = (sel.y + dy).clamp(0.0, self.ih - sel.h); + self.sel = Some(R::new(x, y, sel.w, sel.h)); + } + } + _ if ctrl => match key.to_lower() { + k::c => self.finish(Kind::Copy), + k::s if shift => self.finish(Kind::SaveAs), + k::s => self.finish(Kind::Save), + k::z if shift => self.do_redo(), + k::z => self.do_undo(), + k::y => self.do_redo(), + k::t => self.finish(Kind::Ocr), + k::p => self.finish(Kind::Pin), + k::b => self.cycle_beautify(), + _ => return false, + }, + _ => { + let Some(c) = key.to_lower().to_unicode() else { + return false; + }; + if c == 'f' && self.flavor != Flavor::Edit { + self.sel = Some(self.whole()); + if matches!(self.flavor, Flavor::Quick | Flavor::Ocr) { + self.finish(if self.flavor == Flavor::Ocr { + Kind::Ocr + } else { + Kind::Copy + }); + } + } else if let Some(d) = c.to_digit(10).filter(|d| (1..=8).contains(d)) { + self.set_color(d as usize - 1); + } else if let Some((t, ..)) = TOOLS.iter().find(|(_, _, key)| *key == c) { + self.set_tool(*t); + } else { + return false; + } + } + } + true + } + + // ── Drawing ────────────────────────────────────────────────────────── + + fn draw(&mut self, cr: &Context, aw: f64, ah: f64) { + if self.flavor == Flavor::Edit { + // Room for the action bar beside the image and the tool bar below it. + let (mx, my) = (72.0, 64.0); + self.s = (self.iw / (aw - 2.0 * mx).max(50.0)) + .max(self.ih / (ah - 2.0 * my).max(50.0)) + .max(0.5); + self.ox = ((aw - self.iw / self.s) / 2.0).round(); + self.oy = ((ah - self.ih / self.s) / 2.0).round(); + cr.set_source_rgb(0.12, 0.12, 0.13); + let _ = cr.paint(); + } + + // Frozen screen + annotations, in image space. + let _ = cr.save(); + cr.translate(self.ox, self.oy); + cr.scale(1.0 / self.s, 1.0 / self.s); + cr.rectangle(0.0, 0.0, self.iw, self.ih); + cr.clip(); + let pattern = SurfacePattern::create(&self.bg); + pattern.set_filter(if (self.s - 1.0).abs() < 1e-6 { + Filter::Fast + } else { + Filter::Good + }); + let _ = cr.set_source(&pattern); + let _ = cr.paint(); + if let Some(sel) = self.sel { + self.render_annotations(cr, sel, true); + } + // Dim everything that is not (going to be) captured. + cr.set_fill_rule(cairo::FillRule::EvenOdd); + cr.rectangle(0.0, 0.0, self.iw, self.ih); + let hover_win = if self.picking() && !self.dragging_sel() { + self.pointer.and_then(|w| self.window_at(self.to_img(w))) + } else { + None + }; + if let Some(sel) = self.sel.filter(|s| s.w > 0.0 && s.h > 0.0) { + cr.rectangle(sel.x, sel.y, sel.w, sel.h); + } else if let Some(win) = hover_win { + cr.rectangle(win.x, win.y, win.w, win.h); + } + cr.set_source_rgba( + 0.0, + 0.0, + 0.0, + if self.flavor == Flavor::Edit { + 0.55 + } else { + 0.42 + }, + ); + let _ = cr.fill(); + cr.set_fill_rule(cairo::FillRule::Winding); + let _ = cr.restore(); + + // Chrome, in window space. + cr.set_antialias(cairo::Antialias::Best); + if let Some(sel) = self.sel { + self.draw_selection(cr, sel); + } else if let Some(win) = hover_win { + let r = self.rect_to_win(win); + cr.rectangle(r.x + 1.0, r.y + 1.0, r.w - 2.0, r.h - 2.0); + cr.set_source_rgba(0.25, 0.6, 1.0, 0.95); + cr.set_line_width(2.5); + let _ = cr.stroke(); + } + + self.buttons.clear(); + if self.show_toolbars() { + self.draw_toolbars(cr, aw, ah); + } + + let loupe = self.cfg.show_magnifier + && self.flavor != Flavor::Edit + && (self.picking() || matches!(self.drag, Drag::Resize { .. })); + if loupe && let Some(w) = self.pointer { + self.draw_crosshair(cr, w, aw, ah); + self.draw_loupe(cr, w, aw, ah); + } + + if self.sel.is_none() && !matches!(self.drag, Drag::NewSel { moved: true, .. }) { + let hint = match self.flavor { + Flavor::Ocr => "Select the text to copy · Esc to cancel", + Flavor::Window => "Click a window · drag to select an area · Esc to cancel", + _ if self.windows.is_empty() || !self.snap => { + "Drag to select · click for the whole screen · Esc to cancel" + } + _ => "Drag to select · click a window · F full screen · Esc to cancel", + }; + pill(cr, hint, aw / 2.0, 36.0, 14.0, true); + } + + if let Some((msg, at)) = &self.toast { + if at.elapsed() < Duration::from_millis(1800) { + pill(cr, msg, aw / 2.0, 36.0, 14.0, true); + } else { + self.toast = None; + } + } + + if matches!(self.drag, Drag::None) + && let Some(w) = self.pointer + && let Some((_, btn)) = self.buttons.iter().find(|(r, _)| r.contains(w)) + { + let text = btn.tooltip(self); + let (bx, by) = (w.0, w.1 - 28.0); + pill( + cr, + &text, + bx.clamp(120.0, aw - 120.0), + by.max(16.0), + 12.0, + true, + ); + } + } + + fn dragging_sel(&self) -> bool { + matches!(self.drag, Drag::NewSel { moved: true, .. }) + } + + fn draw_selection(&self, cr: &Context, sel: R) { + let r = self.rect_to_win(sel); + cr.rectangle(r.x - 0.5, r.y - 0.5, r.w + 1.0, r.h + 1.0); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.9); + cr.set_line_width(1.0); + let _ = cr.stroke(); + + let busy = matches!(self.drag, Drag::Draw { .. } | Drag::MoveAnn { .. }); + if !busy && self.flavor != Flavor::Ocr && self.flavor != Flavor::Quick { + for h in self.handles() { + cr.arc(h.0, h.1, HANDLE, 0.0, std::f64::consts::TAU); + cr.set_source_rgb(1.0, 1.0, 1.0); + let _ = cr.fill_preserve(); + cr.set_source_rgba(0.0, 0.48, 1.0, 1.0); + cr.set_line_width(1.5); + let _ = cr.stroke(); + } + } + + let label = format!("{} × {}", sel.w as i64, sel.h as i64); + let y = if r.y > 30.0 { r.y - 16.0 } else { r.y + 16.0 }; + let (tw, _) = text_size(cr, &label, 12.0); + pill(cr, &label, r.x + tw / 2.0 + 10.0, y, 12.0, false); + } + + fn draw_crosshair(&self, cr: &Context, w: P, aw: f64, ah: f64) { + cr.set_line_width(1.0); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.35); + cr.move_to(0.0, w.1.floor() + 0.5); + cr.line_to(aw, w.1.floor() + 0.5); + cr.move_to(w.0.floor() + 0.5, 0.0); + cr.line_to(w.0.floor() + 0.5, ah); + let _ = cr.stroke(); + } + + fn draw_loupe(&self, cr: &Context, w: P, aw: f64, ah: f64) { + const SIZE: f64 = 120.0; + const ZOOM: f64 = 10.0; + let p = self.to_img(w); + let (px, py) = (p.0.floor(), p.1.floor()); + let mut cx = w.0 + 28.0 + SIZE / 2.0; + let mut cy = w.1 + 28.0 + SIZE / 2.0; + if cx + SIZE / 2.0 > aw - 8.0 { + cx = w.0 - 28.0 - SIZE / 2.0; + } + if cy + SIZE / 2.0 + 30.0 > ah - 8.0 { + cy = w.1 - 28.0 - SIZE / 2.0 - 30.0; + } + + let _ = cr.save(); + cr.arc(cx, cy, SIZE / 2.0, 0.0, std::f64::consts::TAU); + cr.clip(); + cr.set_source_rgb(0.1, 0.1, 0.1); + let _ = cr.paint(); + cr.translate(cx, cy); + cr.scale(ZOOM, ZOOM); + cr.translate(-px - 0.5, -py - 0.5); + let pattern = SurfacePattern::create(&self.bg); + pattern.set_filter(Filter::Nearest); + let _ = cr.set_source(&pattern); + let _ = cr.paint(); + let _ = cr.restore(); + + // Centre pixel outline + ring. + cr.rectangle(cx - ZOOM / 2.0, cy - ZOOM / 2.0, ZOOM, ZOOM); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.95); + cr.set_line_width(1.5); + let _ = cr.stroke(); + cr.arc(cx, cy, SIZE / 2.0, 0.0, std::f64::consts::TAU); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.85); + cr.set_line_width(2.0); + let _ = cr.stroke(); + + let color = self + .pixel(p) + .map(|(r, g, b)| format!("#{r:02X}{g:02X}{b:02X}")) + .unwrap_or_default(); + let label = format!("{}, {} {color}", px.max(0.0), py.max(0.0)); + pill(cr, &label, cx, cy + SIZE / 2.0 + 16.0, 11.0, false); + } + + fn draw_toolbars(&mut self, cr: &Context, aw: f64, ah: f64) { + let Some(sel) = self.sel else { return }; + let r = self.rect_to_win(sel); + + // Horizontal tool bar: tools | colours | sizes | contextual option. + let mut items: Vec = TOOLS.iter().map(|(t, ..)| Btn::Tool(*t)).collect(); + let sep1 = items.len(); + items.extend((0..COLORS.len()).map(Btn::Color)); + let sep2 = items.len(); + items.extend((0..SIZES.len()).map(Btn::Size)); + match self.tool { + Tool::Rect | Tool::Ellipse => items.push(Btn::Fill), + Tool::Censor => items.push(Btn::Censor), + _ => {} + } + let seps = [sep1, sep2]; + let bar_w = items.len() as f64 * BTN + 2.0 * BAR_PAD + seps.len() as f64 * 9.0; + let bar_h = BTN + 2.0 * BAR_PAD; + let mut y = r.bottom() + 10.0; + if y + bar_h > ah - 6.0 { + y = r.y - 10.0 - bar_h; + } + if y < 6.0 { + y = r.bottom() - bar_h - 10.0; + } + let x = (r.x + r.w / 2.0 - bar_w / 2.0).clamp(6.0, (aw - bar_w - 6.0).max(6.0)); + bar_background(cr, R::new(x, y, bar_w, bar_h)); + let mut bx = x + BAR_PAD; + for (i, btn) in items.iter().enumerate() { + if seps.contains(&i) { + cr.set_source_rgba(1.0, 1.0, 1.0, 0.18); + cr.rectangle(bx + 3.5, y + 9.0, 1.0, bar_h - 18.0); + let _ = cr.fill(); + bx += 9.0; + } + let rect = R::new(bx, y + BAR_PAD, BTN, BTN); + self.draw_button(cr, rect, *btn); + self.buttons.push((rect, *btn)); + bx += BTN; + } + + // Vertical action bar beside the selection. + let actions = [ + Btn::Undo, + Btn::Redo, + Btn::Ocr, + Btn::Pin, + Btn::Beautify, + Btn::Save, + Btn::Copy, + Btn::Close, + ]; + let col_w = BTN + 2.0 * BAR_PAD; + let col_h = actions.len() as f64 * BTN + 2.0 * BAR_PAD; + let mut cx = r.right() + 10.0; + if cx + col_w > aw - 6.0 { + cx = r.x - 10.0 - col_w; + } + if cx < 6.0 { + cx = r.right() - col_w - 10.0; + } + let cy = (r.bottom() - col_h).clamp(6.0, (ah - col_h - 6.0).max(6.0)); + bar_background(cr, R::new(cx, cy, col_w, col_h)); + for (i, btn) in actions.iter().enumerate() { + let rect = R::new(cx + BAR_PAD, cy + BAR_PAD + i as f64 * BTN, BTN, BTN); + self.draw_button(cr, rect, *btn); + self.buttons.push((rect, *btn)); + } + } + + fn draw_button(&self, cr: &Context, r: R, btn: Btn) { + let hovered = self.pointer.is_some_and(|p| r.contains(p)); + let active = match btn { + Btn::Tool(t) => t == self.tool, + Btn::Size(i) => i == self.size, + Btn::Fill => self.fill, + Btn::Beautify => self.beautify.is_some(), + _ => false, + }; + if active || hovered { + rounded_rect(cr, r.x + 2.0, r.y + 2.0, r.w - 4.0, r.h - 4.0, 6.0); + if active { + cr.set_source_rgba(0.04, 0.52, 1.0, 0.95); + } else { + cr.set_source_rgba(1.0, 1.0, 1.0, 0.12); + } + let _ = cr.fill(); + } + let disabled = match btn { + Btn::Undo => self.undo.is_empty(), + Btn::Redo => self.redo.is_empty(), + _ => false, + }; + let alpha = if disabled { 0.35 } else { 0.95 }; + let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0); + cr.set_source_rgba(1.0, 1.0, 1.0, alpha); + cr.set_line_width(1.6); + cr.set_line_cap(cairo::LineCap::Round); + cr.set_line_join(cairo::LineJoin::Round); + draw_icon(cr, btn, cx, cy, self); + } + + /// Draw all annotations (plus the one being drawn and the text being + /// typed, when `live`), clipped to the selection. + fn render_annotations(&self, cr: &Context, sel: R, live: bool) { + let _ = cr.save(); + cr.rectangle(sel.x, sel.y, sel.w, sel.h); + cr.clip(); + let replacing = self.text.as_ref().and_then(|t| t.replacing); + let mut spots: Vec = Vec::new(); + let mut draw = |ann: &Ann| { + if let Shape::Spotlight(r) = ann.shape { + spots.push(r); + } else { + draw_ann(cr, ann, &self.bg, self.k); + } + }; + for (i, ann) in self.anns.iter().enumerate() { + if Some(i) != replacing { + draw(ann); + } + } + if live && let Drag::Draw { ann, .. } = &self.drag { + draw(ann); + } + if !spots.is_empty() { + cr.set_fill_rule(cairo::FillRule::EvenOdd); + cr.rectangle(sel.x, sel.y, sel.w, sel.h); + for s in &spots { + cr.rectangle(s.x, s.y, s.w, s.h); + } + cr.set_source_rgba(0.0, 0.0, 0.0, 0.55); + let _ = cr.fill(); + cr.set_fill_rule(cairo::FillRule::Winding); + } + if live && let Some(edit) = &self.text { + let text = if edit.text.is_empty() { + " " + } else { + &edit.text + }; + draw_text(cr, edit.at, text, edit.size, edit.color); + let (w, h) = measure_text(&edit.text, edit.size); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.9); + cr.set_line_width(1.5 * self.k); + cr.move_to( + edit.at.0 + w + 2.0 * self.k, + edit.at.1 + h.max(edit.size) * 0.1, + ); + cr.line_to( + edit.at.0 + w + 2.0 * self.k, + edit.at.1 + h.max(edit.size * 1.2), + ); + let _ = cr.stroke(); + cr.set_dash(&[4.0 * self.k, 3.0 * self.k], 0.0); + cr.rectangle( + edit.at.0 - 4.0 * self.k, + edit.at.1 - 2.0 * self.k, + w.max(edit.size) + 10.0 * self.k, + h.max(edit.size * 1.2) + 4.0 * self.k, + ); + cr.set_line_width(1.0 * self.k); + let _ = cr.stroke(); + cr.set_dash(&[], 0.0); + } + if live && let Some(ann) = self.selected.and_then(|i| self.anns.get(i)) { + let b = ann.bounds(self.k).inflate(ann.width / 2.0 + 4.0 * self.k); + cr.set_dash(&[5.0 * self.k, 4.0 * self.k], 0.0); + cr.set_source_rgba(0.04, 0.52, 1.0, 1.0); + cr.set_line_width(1.5 * self.k); + cr.rectangle(b.x, b.y, b.w, b.h); + let _ = cr.stroke(); + cr.set_dash(&[], 0.0); + } + let _ = cr.restore(); + } +} + +// ── Annotation rendering ──────────────────────────────────────────────────── + +fn set_rgb(cr: &Context, c: Rgb, a: f64) { + cr.set_source_rgba(c.0, c.1, c.2, a); +} + +fn draw_ann(cr: &Context, ann: &Ann, bg: &ImageSurface, k: f64) { + let w = ann.width; + // Text layout leaves a current point behind; never connect to it. + cr.new_path(); + cr.set_line_cap(cairo::LineCap::Round); + cr.set_line_join(cairo::LineJoin::Round); + cr.set_line_width(w); + set_rgb(cr, ann.color, 1.0); + match &ann.shape { + Shape::Line(a, b) => { + cr.move_to(a.0, a.1); + cr.line_to(b.0, b.1); + let _ = cr.stroke(); + } + Shape::Arrow(a, b) => { + let len = dist(*a, *b); + if len < 1.0 { + return; + } + let head = (w * 3.2 + 9.0 * k).min(len * 0.7); + let ang = (b.1 - a.1).atan2(b.0 - a.0); + let spread = 0.45; + let base = (b.0 - head * 0.8 * ang.cos(), b.1 - head * 0.8 * ang.sin()); + cr.move_to(a.0, a.1); + cr.line_to(base.0, base.1); + let _ = cr.stroke(); + cr.move_to(b.0, b.1); + cr.line_to( + b.0 - head * (ang - spread).cos(), + b.1 - head * (ang - spread).sin(), + ); + cr.line_to( + b.0 - head * (ang + spread).cos(), + b.1 - head * (ang + spread).sin(), + ); + cr.close_path(); + let _ = cr.fill_preserve(); + cr.set_line_width(w * 0.6); + let _ = cr.stroke(); + } + Shape::Rect(r) => { + rounded_rect(cr, r.x, r.y, r.w, r.h, 2.0 * k); + if ann.fill { + set_rgb(cr, ann.color, 0.35); + let _ = cr.fill_preserve(); + set_rgb(cr, ann.color, 1.0); + } + let _ = cr.stroke(); + } + Shape::Ellipse(r) => { + if r.w < 1.0 || r.h < 1.0 { + return; + } + let _ = cr.save(); + cr.translate(r.x + r.w / 2.0, r.y + r.h / 2.0); + cr.scale(r.w / 2.0, r.h / 2.0); + cr.arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::TAU); + let _ = cr.restore(); + if ann.fill { + set_rgb(cr, ann.color, 0.35); + let _ = cr.fill_preserve(); + set_rgb(cr, ann.color, 1.0); + } + let _ = cr.stroke(); + } + Shape::Pen(pts) => { + smooth_path(cr, pts); + let _ = cr.stroke(); + } + Shape::Marker(pts) => { + let _ = cr.save(); + cr.set_operator(Operator::Multiply); + cr.set_line_cap(cairo::LineCap::Square); + cr.set_line_width(w * 3.0 + 8.0 * k); + set_rgb(cr, ann.color, 0.45); + smooth_path(cr, pts); + let _ = cr.stroke(); + let _ = cr.restore(); + } + Shape::Text { at, text, size, .. } => draw_text(cr, *at, text, *size, ann.color), + Shape::Number { at, n } => { + let r = number_radius(w, k); + cr.arc(at.0, at.1, r, 0.0, std::f64::consts::TAU); + let _ = cr.fill_preserve(); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.95); + cr.set_line_width(1.5 * k); + let _ = cr.stroke(); + let light = ann.color.0 * 0.3 + ann.color.1 * 0.59 + ann.color.2 * 0.11 > 0.7; + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans Bold"); + font.set_absolute_size(r * 1.15 * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(&n.to_string()); + let (_, logical) = layout.pixel_extents(); + cr.move_to( + at.0 - f64::from(logical.width()) / 2.0, + at.1 - f64::from(logical.height()) / 2.0, + ); + if light { + cr.set_source_rgb(0.0, 0.0, 0.0); + } else { + cr.set_source_rgb(1.0, 1.0, 1.0); + } + pangocairo::functions::show_layout(cr, &layout); + } + Shape::Censor { r, mode, blurred } => match mode { + CensorMode::Solid => { + cr.rectangle(r.x, r.y, r.w, r.h); + let _ = cr.fill(); + } + CensorMode::Blur if blurred.is_some() => { + if let Some(patch) = blurred { + let _ = cr.set_source_surface(patch, r.x.floor(), r.y.floor()); + cr.rectangle(r.x, r.y, r.w, r.h); + let _ = cr.fill(); + } + } + // Pixelate — also the live preview for blur while dragging. + _ => pixelate(cr, bg, *r, k), + }, + Shape::Spotlight(_) => {} + } +} + +/// Smooth freehand path through `pts` (quadratic curves via midpoints). +fn smooth_path(cr: &Context, pts: &[P]) { + let Some(first) = pts.first() else { return }; + cr.move_to(first.0, first.1); + if pts.len() == 1 { + cr.line_to(first.0 + 0.01, first.1); + return; + } + for w in pts.windows(2) { + let (a, b) = (w[0], w[1]); + let mid = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let (x0, y0) = cr.current_point().unwrap_or(a); + // Quadratic (a as control) expressed as a cubic. + cr.curve_to( + x0 + 2.0 / 3.0 * (a.0 - x0), + y0 + 2.0 / 3.0 * (a.1 - y0), + mid.0 + 2.0 / 3.0 * (a.0 - mid.0), + mid.1 + 2.0 / 3.0 * (a.1 - mid.1), + mid.0, + mid.1, + ); + } + if let Some(last) = pts.last() { + cr.line_to(last.0, last.1); + } +} + +fn text_layout(cr: &Context, text: &str, size: f64) -> gtk::pango::Layout { + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans Bold"); + font.set_absolute_size(size * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(text); + layout +} + +fn draw_text(cr: &Context, at: P, text: &str, size: f64, color: Rgb) { + let layout = text_layout(cr, text, size); + // A thin contrasting outline keeps text readable on any background. + let light = color.0 * 0.3 + color.1 * 0.59 + color.2 * 0.11 > 0.6; + cr.move_to(at.0, at.1); + pangocairo::functions::layout_path(cr, &layout); + if light { + cr.set_source_rgba(0.0, 0.0, 0.0, 0.55); + } else { + cr.set_source_rgba(1.0, 1.0, 1.0, 0.75); + } + cr.set_line_width((size / 9.0).max(1.5)); + let _ = cr.stroke(); + cr.move_to(at.0, at.1); + set_rgb(cr, color, 1.0); + pangocairo::functions::show_layout(cr, &layout); +} + +fn measure_text(text: &str, size: f64) -> P { + let Ok(surface) = ImageSurface::create(Format::A8, 1, 1) else { + return (0.0, 0.0); + }; + let Ok(cr) = Context::new(&surface) else { + return (0.0, 0.0); + }; + if text.is_empty() { + return (0.0, size * 1.2); + } + let (w, h) = text_layout(&cr, text, size).pixel_size(); + (f64::from(w), f64::from(h)) +} + +/// Mosaic: draw the area downscaled, then scaled back up without smoothing. +fn pixelate(cr: &Context, bg: &ImageSurface, r: R, k: f64) { + if r.w < 1.0 || r.h < 1.0 { + return; + } + let block = (r.w.min(r.h) / 6.0).clamp(7.0 * k, 16.0 * k); + let (sw, sh) = ( + (r.w / block).ceil().max(1.0) as i32, + (r.h / block).ceil().max(1.0) as i32, + ); + let Ok(small) = ImageSurface::create(Format::ARgb32, sw, sh) else { + return; + }; + if let Ok(sc) = Context::new(&small) { + sc.scale(f64::from(sw) / r.w, f64::from(sh) / r.h); + sc.translate(-r.x, -r.y); + let pattern = SurfacePattern::create(bg); + pattern.set_filter(Filter::Good); + let _ = sc.set_source(&pattern); + let _ = sc.paint(); + } + let _ = cr.save(); + cr.rectangle(r.x, r.y, r.w, r.h); + cr.clip(); + cr.translate(r.x, r.y); + cr.scale(r.w / f64::from(sw), r.h / f64::from(sh)); + let pattern = SurfacePattern::create(&small); + pattern.set_filter(Filter::Nearest); + let _ = cr.set_source(&pattern); + let _ = cr.paint(); + let _ = cr.restore(); +} + +/// A heavily blurred copy of `r` (computed once, when the censor is placed). +fn blur_patch(bg: &ImageSurface, r: R, k: f64) -> Option { + let r = r.clamp_to(f64::from(bg.width()), f64::from(bg.height())); + let (w, h) = (r.w as i32, r.h as i32); + if w < 1 || h < 1 { + return None; + } + let mut patch = ImageSurface::create(Format::ARgb32, w, h).ok()?; + { + let cr = Context::new(&patch).ok()?; + cr.set_source_surface(bg, -r.x, -r.y).ok()?; + cr.paint().ok()?; + } + let stride = patch.stride(); + { + let mut data = patch.data().ok()?; + let radius = ((w.min(h) as f64 / 8.0).clamp(4.0 * k, 14.0 * k)) as i32; + beautify::box_blur(&mut data, w, h, stride, radius.max(2)); + } + Some(patch) +} + +fn crop_surface(bg: &ImageSurface, r: R) -> Option { + let (x, y, w, h) = (r.x as i32, r.y as i32, r.w as i32, r.h as i32); + if w <= 0 || h <= 0 { + return None; + } + let mut out = None; + let stride = bg.stride(); + bg.with_data(|d| { + let start = (y * stride + x * 4) as usize; + out = Some(bgra_to_rgba(&d[start..], w, h, stride)); + }) + .ok()?; + out +} + +// ── Chrome ────────────────────────────────────────────────────────────────── + +fn text_size(cr: &Context, text: &str, size: f64) -> P { + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans"); + font.set_absolute_size(size * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(text); + let (w, h) = layout.pixel_size(); + (f64::from(w), f64::from(h)) +} + +/// A rounded dark label centred on `(cx, cy)`. +fn pill(cr: &Context, text: &str, cx: f64, cy: f64, size: f64, strong: bool) { + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans"); + font.set_absolute_size(size * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(text); + let (w, h) = layout.pixel_size(); + let (w, h) = (f64::from(w), f64::from(h)); + let (px, py) = ( + if strong { 14.0 } else { 8.0 }, + if strong { 8.0 } else { 4.0 }, + ); + rounded_rect( + cr, + cx - w / 2.0 - px, + cy - h / 2.0 - py, + w + 2.0 * px, + h + 2.0 * py, + (h + 2.0 * py) / 2.0, + ); + cr.set_source_rgba(0.09, 0.09, 0.1, if strong { 0.88 } else { 0.75 }); + let _ = cr.fill(); + cr.move_to(cx - w / 2.0, cy - h / 2.0); + cr.set_source_rgb(1.0, 1.0, 1.0); + pangocairo::functions::show_layout(cr, &layout); +} + +fn bar_background(cr: &Context, r: R) { + rounded_rect(cr, r.x, r.y, r.w, r.h, 10.0); + cr.set_source_rgba(0.11, 0.11, 0.12, 0.94); + let _ = cr.fill_preserve(); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.1); + cr.set_line_width(1.0); + let _ = cr.stroke(); +} + +/// Toolbar icons, drawn as small vector glyphs centred on `(cx, cy)`. +fn draw_icon(cr: &Context, btn: Btn, cx: f64, cy: f64, ed: &Editor) { + use std::f64::consts::{PI, TAU}; + let stroke = || { + let _ = cr.stroke(); + }; + cr.new_path(); + match btn { + Btn::Tool(Tool::Select) => { + cr.move_to(cx - 5.0, cy - 8.0); + cr.line_to(cx - 5.0, cy + 7.0); + cr.line_to(cx - 1.5, cy + 3.5); + cr.line_to(cx + 1.5, cy + 9.0); + cr.line_to(cx + 3.5, cy + 8.0); + cr.line_to(cx + 0.8, cy + 2.5); + cr.line_to(cx + 6.0, cy + 2.5); + cr.close_path(); + stroke(); + } + Btn::Tool(Tool::Arrow) => { + cr.move_to(cx - 7.0, cy + 7.0); + cr.line_to(cx + 6.0, cy - 6.0); + stroke(); + cr.move_to(cx + 7.0, cy - 7.0); + cr.line_to(cx - 0.5, cy - 5.0); + cr.line_to(cx + 5.0, cy + 0.5); + cr.close_path(); + let _ = cr.fill(); + } + Btn::Tool(Tool::Line) => { + cr.move_to(cx - 7.0, cy + 7.0); + cr.line_to(cx + 7.0, cy - 7.0); + stroke(); + } + Btn::Tool(Tool::Rect) => { + rounded_rect(cr, cx - 8.0, cy - 6.0, 16.0, 12.0, 2.0); + stroke(); + } + Btn::Tool(Tool::Ellipse) => { + cr.arc(cx, cy, 7.5, 0.0, TAU); + stroke(); + } + Btn::Tool(Tool::Pen) => { + cr.move_to(cx - 8.0, cy + 4.0); + cr.curve_to(cx - 4.0, cy - 8.0, cx, cy + 10.0, cx + 4.0, cy - 2.0); + cr.curve_to(cx + 5.0, cy - 5.0, cx + 7.0, cy - 6.0, cx + 8.0, cy - 6.0); + stroke(); + } + Btn::Tool(Tool::Marker) => { + let _ = cr.save(); + cr.set_source_rgba(1.0, 0.85, 0.1, 0.9); + cr.set_line_width(6.0); + cr.set_line_cap(cairo::LineCap::Butt); + cr.move_to(cx - 8.0, cy + 2.0); + cr.line_to(cx + 8.0, cy + 2.0); + stroke(); + let _ = cr.restore(); + cr.move_to(cx - 8.0, cy - 5.0); + cr.line_to(cx + 8.0, cy - 5.0); + stroke(); + } + Btn::Tool(Tool::Text) => { + cr.move_to(cx - 6.5, cy - 7.0); + cr.line_to(cx + 6.5, cy - 7.0); + cr.move_to(cx, cy - 7.0); + cr.line_to(cx, cy + 8.0); + stroke(); + } + Btn::Tool(Tool::Number) => { + cr.arc(cx, cy, 8.0, 0.0, TAU); + stroke(); + glyph(cr, "1", cx, cy, 10.0); + } + Btn::Tool(Tool::Censor) => { + for i in 0..3 { + for j in 0..3 { + if (i + j) % 2 == 0 { + cr.rectangle( + cx - 7.5 + i as f64 * 5.0, + cy - 7.5 + j as f64 * 5.0, + 5.0, + 5.0, + ); + } + } + } + let _ = cr.fill(); + cr.rectangle(cx - 7.5, cy - 7.5, 15.0, 15.0); + cr.set_line_width(1.0); + stroke(); + } + Btn::Tool(Tool::Spotlight) => { + cr.rectangle(cx - 8.0, cy - 7.0, 16.0, 14.0); + stroke(); + cr.arc(cx, cy, 3.5, 0.0, TAU); + let _ = cr.fill(); + } + Btn::Tool(Tool::Picker) => { + cr.move_to(cx - 7.0, cy + 7.0); + cr.line_to(cx + 2.0, cy - 2.0); + stroke(); + cr.arc(cx + 4.0, cy - 4.0, 3.5, 0.0, TAU); + let _ = cr.fill(); + } + Btn::Color(i) => { + let c = COLORS[i].0; + cr.arc(cx, cy, 7.5, 0.0, TAU); + set_rgb(cr, c, 1.0); + let _ = cr.fill_preserve(); + cr.set_source_rgba(1.0, 1.0, 1.0, 0.35); + cr.set_line_width(1.0); + stroke(); + if c == ed.color { + cr.arc(cx, cy, 10.5, 0.0, TAU); + cr.set_source_rgb(1.0, 1.0, 1.0); + cr.set_line_width(2.0); + stroke(); + } + } + Btn::Size(i) => { + cr.arc(cx, cy, [2.0, 3.5, 5.5][i], 0.0, TAU); + let _ = cr.fill(); + } + Btn::Fill => { + rounded_rect(cr, cx - 7.0, cy - 6.0, 14.0, 12.0, 2.0); + if ed.fill { + let _ = cr.fill(); + } else { + stroke(); + } + } + Btn::Censor => glyph(cr, &ed.censor.label()[..1], cx, cy, 13.0), + Btn::Undo | Btn::Redo => { + let dir = if btn == Btn::Undo { 1.0 } else { -1.0 }; + let _ = cr.save(); + cr.translate(cx, cy); + cr.scale(dir, 1.0); + cr.arc(1.0, 1.0, 6.0, PI, PI * 2.6); + stroke(); + cr.move_to(-8.5, -2.0); + cr.line_to(-5.0, 2.5); + cr.line_to(-1.5, -2.0); + stroke(); + let _ = cr.restore(); + } + Btn::Ocr => { + for (sx, sy) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] { + cr.move_to(cx + sx * 8.0, cy + sy * 4.0); + cr.line_to(cx + sx * 8.0, cy + sy * 8.0); + cr.line_to(cx + sx * 4.0, cy + sy * 8.0); + } + stroke(); + glyph(cr, "T", cx, cy, 11.0); + } + Btn::Pin => { + cr.arc(cx, cy - 3.5, 4.5, 0.0, TAU); + stroke(); + cr.move_to(cx, cy + 1.0); + cr.line_to(cx, cy + 9.0); + stroke(); + } + Btn::Beautify => { + for i in 0..8 { + let a = f64::from(i) * PI / 4.0; + let r = if i % 2 == 0 { 8.5 } else { 3.0 }; + let (x, y) = (cx + r * a.cos(), cy + r * a.sin()); + if i == 0 { + cr.move_to(x, y); + } else { + cr.line_to(x, y); + } + } + cr.close_path(); + let _ = cr.fill(); + } + Btn::Save => { + cr.move_to(cx, cy - 8.0); + cr.line_to(cx, cy + 3.0); + cr.move_to(cx - 4.5, cy - 1.5); + cr.line_to(cx, cy + 3.0); + cr.line_to(cx + 4.5, cy - 1.5); + cr.move_to(cx - 8.0, cy + 3.0); + cr.line_to(cx - 8.0, cy + 8.0); + cr.line_to(cx + 8.0, cy + 8.0); + cr.line_to(cx + 8.0, cy + 3.0); + stroke(); + } + Btn::Copy => { + rounded_rect(cr, cx - 7.0, cy - 4.0, 11.0, 12.0, 2.0); + stroke(); + cr.move_to(cx - 3.0, cy - 6.5); + cr.line_to(cx - 3.0, cy - 8.0); + cr.line_to(cx + 8.0, cy - 8.0); + cr.line_to(cx + 8.0, cy + 4.0); + cr.line_to(cx + 6.5, cy + 4.0); + stroke(); + } + Btn::Close => { + cr.move_to(cx - 6.0, cy - 6.0); + cr.line_to(cx + 6.0, cy + 6.0); + cr.move_to(cx + 6.0, cy - 6.0); + cr.line_to(cx - 6.0, cy + 6.0); + stroke(); + } + } +} + +fn glyph(cr: &Context, text: &str, cx: f64, cy: f64, size: f64) { + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans Bold"); + font.set_absolute_size(size * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(text); + let (_, logical) = layout.pixel_extents(); + cr.move_to( + cx - f64::from(logical.width()) / 2.0, + cy - f64::from(logical.height()) / 2.0, + ); + pangocairo::functions::show_layout(cr, &layout); +} + +// ── Windows & lifecycle ───────────────────────────────────────────────────── + +/// Capture modes: freeze the screen and run the overlay on the monitor under +/// the pointer. +pub fn run_capture(flavor: Flavor, cfg: ScreenshotConfig) { + let Some(display) = gdk::Display::default() else { + return; + }; + let pointer = display + .default_seat() + .and_then(|s| s.pointer()) + .map(|p| { + let (_, x, y) = p.position(); + (x, y) + }) + .unwrap_or((0, 0)); + let Some(monitor) = display + .monitor_at_point(pointer.0, pointer.1) + .or_else(|| display.primary_monitor()) + else { + return; + }; + let geo = monitor.geometry(); + let sf = f64::from(monitor.scale_factor().max(1)); + + // Bounding box of all monitors (logical) — what the grab covers. + let (mut bx0, mut by0, mut bx1, mut by1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for i in 0..display.n_monitors() { + if let Some(m) = display.monitor(i) { + let g = m.geometry(); + bx0 = bx0.min(g.x()); + by0 = by0.min(g.y()); + bx1 = bx1.max(g.x() + g.width()); + by1 = by1.max(g.y() + g.height()); + } + } + + let frame = match grab::grab_desktop() { + Ok(f) => f, + Err(e) => { + error_dialog(&e); + return; + } + }; + let rx = f64::from(frame.width) / (f64::from(bx1 - bx0) * sf); + let ry = f64::from(frame.height) / (f64::from(by1 - by0) * sf); + let crop_x = (f64::from(geo.x() - bx0) * sf * rx).round(); + let crop_y = (f64::from(geo.y() - by0) * sf * ry).round(); + let frame = frame.crop( + crop_x as i32, + crop_y as i32, + (f64::from(geo.width()) * sf * rx).round() as i32, + (f64::from(geo.height()) * sf * ry).round() as i32, + ); + let k = f64::from(frame.width) / f64::from(geo.width()); + + let windows: Vec = if crate::recorder::portal::is_wayland_session() { + Vec::new() + } else { + grab::visible_windows() + .into_iter() + .map(|(x, y, w, h)| { + R::new( + (f64::from(x) - f64::from(bx0) * sf) * rx - crop_x, + (f64::from(y) - f64::from(by0) * sf) * ry - crop_y, + f64::from(w) * rx, + f64::from(h) * ry, + ) + }) + .collect() + }; + + let Some(bg) = frame_surface(frame) else { + return; + }; + + if flavor == Flavor::Fullscreen { + let mut ed = Editor::new(flavor, cfg.clone(), bg, k); + let whole = ed.whole(); + ed.sel = Some(whole); + let kind = if cfg.enter_action.eq_ignore_ascii_case("save") { + Kind::Save + } else { + Kind::Copy + }; + let outcome = ed.export(whole).map(|img| Outcome { kind, img }); + drop(ed); + if let Some(o) = outcome { + perform(o, cfg); + } + return; + } + + let mut ed = Editor::new(flavor, cfg.clone(), bg, k); + ed.windows = windows; + + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("RustCast Capture"); + window.set_decorated(false); + window.set_skip_taskbar_hint(true); + window.set_skip_pager_hint(true); + window.set_keep_above(true); + window.move_(geo.x(), geo.y()); + window.set_default_size(geo.width(), geo.height()); + window.fullscreen(); + + let outcome = run_window(window, ed); + if let Some(o) = outcome { + perform(o, cfg); + } +} + +/// Edit mode: annotate an existing image in a normal window. +pub fn run_file(path: &Path, cfg: ScreenshotConfig) { + let img = match image::open(path) { + Ok(i) => i.into_rgba8(), + Err(e) => { + error_dialog(&format!("Cannot open {}: {e}", path.display())); + return; + } + }; + let Some(bg) = frame_surface(Frame::from_rgba(img)) else { + return; + }; + let mut ed = Editor::new(Flavor::Edit, cfg.clone(), bg, 1.0); + ed.sel = Some(ed.whole()); + ed.snap = false; + + let (mw, mh) = gdk::Display::default() + .and_then(|d| d.primary_monitor().or_else(|| d.monitor(0))) + .map(|m| { + ( + f64::from(m.workarea().width()), + f64::from(m.workarea().height()), + ) + }) + .unwrap_or((1600.0, 900.0)); + let w = (ed.iw + 144.0).clamp(760.0, mw * 0.9); + let h = (ed.ih + 128.0).clamp(520.0, mh * 0.9); + + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title(&format!( + "Annotate — {}", + path.file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default() + )); + window.set_default_size(w as i32, h as i32); + window.set_position(gtk::WindowPosition::Center); + + if let Some(o) = run_window(window, ed) { + perform(o, cfg); + } +} + +fn frame_surface(frame: Frame) -> Option { + let stride = frame.stride(); + ImageSurface::create_for_data( + frame.data, + Format::ARgb32, + frame.width, + frame.height, + stride, + ) + .ok() +} + +/// Wire the editor to a window, run GTK until it closes, return the outcome. +fn run_window(window: gtk::Window, ed: Editor) -> Option { + let ed = Rc::new(RefCell::new(ed)); + let area = gtk::DrawingArea::new(); + area.set_can_focus(true); + area.add_events( + gdk::EventMask::BUTTON_PRESS_MASK + | gdk::EventMask::BUTTON_RELEASE_MASK + | gdk::EventMask::POINTER_MOTION_MASK + | gdk::EventMask::LEAVE_NOTIFY_MASK + | gdk::EventMask::KEY_PRESS_MASK, + ); + window.add(&area); + + let check_close = { + let window = window.clone(); + move |ed: &Rc>| { + if ed.borrow().close { + window.hide(); + window.close(); + } + } + }; + + { + let ed = ed.clone(); + area.connect_draw(move |a, cr| { + let (w, h) = ( + f64::from(a.allocated_width()), + f64::from(a.allocated_height()), + ); + ed.borrow_mut().draw(cr, w, h); + glib::Propagation::Stop + }); + } + { + let (ed, check_close) = (ed.clone(), check_close.clone()); + area.connect_button_press_event(move |a, ev| { + if ev.event_type() == gdk::EventType::ButtonPress { + let mut e = ed.borrow_mut(); + e.shift = ev.state().contains(gdk::ModifierType::SHIFT_MASK); + let p = ev.position(); + e.pointer = Some(p); + e.press(p, ev.button()); + } + check_close(&ed); + a.queue_draw(); + glib::Propagation::Stop + }); + } + { + let (ed, check_close) = (ed.clone(), check_close.clone()); + area.connect_button_release_event(move |a, ev| { + let finish = { + let mut e = ed.borrow_mut(); + e.shift = ev.state().contains(gdk::ModifierType::SHIFT_MASK); + e.release(ev.position()) + }; + if finish { + let mut e = ed.borrow_mut(); + if e.flavor == Flavor::Ocr { + e.finish(Kind::Ocr); + } else if e.cfg.enter_action.eq_ignore_ascii_case("save") { + e.finish(Kind::Save); + } else { + e.finish(Kind::Copy); + } + } + check_close(&ed); + a.queue_draw(); + glib::Propagation::Stop + }); + } + { + let ed = ed.clone(); + area.connect_motion_notify_event(move |a, ev| { + let mut e = ed.borrow_mut(); + e.shift = ev.state().contains(gdk::ModifierType::SHIFT_MASK); + e.motion(ev.position()); + let cursor = e.cursor_for(ev.position()); + if cursor != e.cursor { + e.cursor = cursor; + if let Some(win) = a.window() { + let c = gdk::Cursor::from_name(&win.display(), cursor); + win.set_cursor(c.as_ref()); + } + } + a.queue_draw(); + glib::Propagation::Stop + }); + } + { + let ed = ed.clone(); + area.connect_leave_notify_event(move |a, _| { + ed.borrow_mut().pointer = None; + a.queue_draw(); + glib::Propagation::Proceed + }); + } + { + let (ed, check_close, area) = (ed.clone(), check_close.clone(), area.clone()); + window.connect_key_press_event(move |_, ev| { + let handled = ed.borrow_mut().key(ev); + check_close(&ed); + area.queue_draw(); + if handled { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + } + // Repaint once toasts expire. + { + let area = area.clone(); + let ed = ed.clone(); + glib::timeout_add_local(Duration::from_millis(500), move || { + if ed.borrow().toast.is_some() { + area.queue_draw(); + } + glib::ControlFlow::Continue + }); + } + + window.connect_destroy(|_| gtk::main_quit()); + window.show_all(); + window.present(); + area.grab_focus(); + take_focus(&window); + if let Some(win) = area.window() { + let c = gdk::Cursor::from_name(&win.display(), "crosshair"); + win.set_cursor(c.as_ref()); + } + gtk::main(); + + ed.borrow_mut().outcome.take() +} + +impl Editor { + fn cursor_for(&self, w: P) -> &'static str { + if self.buttons.iter().any(|(r, _)| r.contains(w)) { + return "default"; + } + match self.drag { + Drag::MoveSel { .. } | Drag::MoveAnn { .. } => return "grabbing", + Drag::Draw { .. } | Drag::NewSel { .. } => return "crosshair", + _ => {} + } + if self.sel.is_some() && !matches!(self.flavor, Flavor::Quick | Flavor::Ocr) { + if let Some(h) = self.handle_at(w) { + return [ + "nw-resize", + "n-resize", + "ne-resize", + "e-resize", + "se-resize", + "s-resize", + "sw-resize", + "w-resize", + ][h]; + } + let p = self.to_img(w); + match self.tool { + Tool::Select if self.anns.iter().any(|a| a.hit(p, self.k)) => return "grab", + Tool::Select if self.sel.is_some_and(|s| s.contains(p)) => return "move", + Tool::Text => return "text", + _ => {} + } + } + "crosshair" + } +} + +/// Ask the window manager to focus the overlay. On GNOME a window opened by a +/// background process may otherwise come up without keyboard focus. +fn take_focus(window: >k::Window) { + let window = window.clone(); + glib::timeout_add_local_once(Duration::from_millis(60), move || { + window.present_with_time(0); + let me = std::process::id(); + if let Some(w) = crate::platform::linux::x11::client_windows() + .into_iter() + .find(|w| w.pid == Some(me)) + { + crate::platform::linux::x11::focus_window(w.id); + } + }); +} + +/// Carry out what the user chose, after the overlay is gone. +fn perform(outcome: Outcome, cfg: ScreenshotConfig) { + let Outcome { kind, img } = outcome; + match kind { + Kind::Copy => { + let dir = crate::persist::screenshots_dir(); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join(super::timestamp_name("rustcast", "png")); + if super::write_image_to(&img, &path, &cfg) { + drop(img); + super::copy_image(&path); + if !super::main_instance_running() { + super::linger_for_clipboard(); + } + } + } + Kind::Save => { + if super::write_image(&img, &cfg.save_dir(), &cfg).is_none() { + error_dialog(&format!("Could not save to {}", cfg.save_dir().display())); + } + } + Kind::SaveAs => { + let dialog = gtk::FileChooserNative::new( + Some("Save Screenshot"), + None::<>k::Window>, + gtk::FileChooserAction::Save, + Some("Save"), + Some("Cancel"), + ); + dialog.set_do_overwrite_confirmation(true); + let dir = cfg.save_dir(); + let _ = std::fs::create_dir_all(&dir); + dialog.set_current_folder(&dir); + dialog.set_current_name(&super::timestamp_name("rustcast", cfg.extension())); + if dialog.run() == gtk::ResponseType::Accept + && let Some(path) = dialog.filename() + { + let path = if path.extension().is_none() { + path.with_extension(cfg.extension()) + } else { + path + }; + if !super::write_image_to(&img, &path, &cfg) { + error_dialog(&format!("Could not save {}", path.display())); + } + } + } + Kind::Pin => { + let dir = pins_dir(); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join(super::timestamp_name("pin", "png")); + if super::write_image_to(&img, &path, &cfg) { + super::spawn(&["pin", &path.to_string_lossy()]); + } + } + Kind::Ocr => super::ocr::run_on_image(img, cfg), + } +} + +fn pins_dir() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("rustcast/pins") +} + +fn error_dialog(msg: &str) { + let dialog = gtk::MessageDialog::new( + None::<>k::Window>, + gtk::DialogFlags::MODAL, + gtk::MessageType::Error, + gtk::ButtonsType::Close, + msg, + ); + dialog.set_title("RustCast"); + dialog.set_keep_above(true); + dialog.run(); + dialog.close(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rects_normalise_and_clamp() { + let r = R::from_pts((10.0, 20.0), (4.0, 2.0)); + assert_eq!(r, R::new(4.0, 2.0, 6.0, 18.0)); + let c = R::new(-5.0, 5.0, 50.0, 10.0).clamp_to(30.0, 12.0); + assert_eq!(c, R::new(0.0, 5.0, 30.0, 7.0)); + } + + #[test] + fn shift_constraints() { + let p = snap45((0.0, 0.0), (10.0, 1.0)); + assert!((p.1).abs() < 1e-9 && (p.0 - 10.05).abs() < 0.1); + assert_eq!(square((0.0, 0.0), (10.0, -4.0)), (10.0, -10.0)); + } + + #[test] + fn segment_distance() { + assert_eq!(dist_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0)), 3.0); + assert_eq!(dist_to_segment((-4.0, 3.0), (0.0, 0.0), (10.0, 0.0)), 5.0); + } + + fn editor(w: i32, h: i32) -> Editor { + let bg = ImageSurface::create(Format::ARgb32, w, h).unwrap(); + Editor::new(Flavor::Area, ScreenshotConfig::default(), bg, 1.0) + } + + #[test] + fn click_without_drag_selects_window_or_screen() { + let mut ed = editor(200, 100); + ed.windows = vec![R::new(10.0, 10.0, 50.0, 40.0)]; + ed.press((20.0, 20.0), 1); + ed.release((20.0, 20.0)); + assert_eq!(ed.sel, Some(R::new(10.0, 10.0, 50.0, 40.0))); + + let mut ed = editor(200, 100); + ed.press((150.0, 80.0), 1); + ed.release((150.0, 80.0)); + assert_eq!(ed.sel, Some(R::new(0.0, 0.0, 200.0, 100.0))); + } + + #[test] + fn drawing_and_undo_redo() { + let mut ed = editor(200, 100); + ed.sel = Some(ed.whole()); + ed.tool = Tool::Arrow; + ed.press((10.0, 10.0), 1); + ed.motion((60.0, 40.0)); + ed.release((60.0, 40.0)); + assert_eq!(ed.anns.len(), 1); + ed.do_undo(); + assert!(ed.anns.is_empty()); + ed.do_redo(); + assert_eq!(ed.anns.len(), 1); + + // A click with a drawing tool does not leave a dot behind. + ed.press((90.0, 90.0), 1); + ed.release((90.0, 90.0)); + assert_eq!(ed.anns.len(), 1); + } + + #[test] + fn export_includes_annotations_at_full_size() { + let mut ed = editor(100, 60); + ed.sel = Some(R::new(10.0, 10.0, 40.0, 30.0)); + ed.anns.push(Ann { + shape: Shape::Censor { + r: R::new(10.0, 10.0, 40.0, 30.0), + mode: CensorMode::Solid, + blurred: None, + }, + color: (1.0, 0.0, 0.0), + width: 2.0, + fill: false, + }); + let img = ed.export(ed.sel.unwrap()).unwrap(); + assert_eq!(img.dimensions(), (40, 30)); + assert_eq!(img.get_pixel(20, 15).0, [255, 0, 0, 255]); + } + + #[test] + fn numbered_steps_count_up() { + let mut ed = editor(100, 100); + ed.sel = Some(ed.whole()); + ed.tool = Tool::Number; + ed.press((10.0, 10.0), 1); + ed.release((10.0, 10.0)); + ed.press((50.0, 50.0), 1); + ed.release((50.0, 50.0)); + let ns: Vec = ed + .anns + .iter() + .filter_map(|a| match a.shape { + Shape::Number { n, .. } => Some(n), + _ => None, + }) + .collect(); + assert_eq!(ns, vec![1, 2]); + } +} diff --git a/src/snap/grab.rs b/src/snap/grab.rs new file mode 100644 index 0000000..2eef00e --- /dev/null +++ b/src/snap/grab.rs @@ -0,0 +1,370 @@ +//! Grab the whole desktop as one frozen frame. +//! +//! - X11: a single `GetImage` of the root window. The server hands back BGRX +//! pixels, which is already cairo's `ARGB32` memory layout on little-endian +//! machines, so the frame goes to the editor without any conversion or copy. +//! - Wayland: XWayland cannot see native windows, so ask the compositor through +//! the `xdg-desktop-portal` Screenshot interface (non-interactive), falling back +//! to `grim` / `gnome-screenshot` / `spectacle`. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{AtomEnum, ConnectionExt, ImageFormat, ImageOrder, MapState}; + +/// A captured frame: premultiplied BGRA (cairo `ARGB32`), tightly packed. +pub struct Frame { + pub width: i32, + pub height: i32, + pub data: Vec, +} + +impl Frame { + pub fn stride(&self) -> i32 { + self.width * 4 + } + + /// Copy out the `(x, y, w, h)` sub-rectangle (clamped to the frame). + pub fn crop(self, x: i32, y: i32, w: i32, h: i32) -> Frame { + let x = x.clamp(0, self.width - 1); + let y = y.clamp(0, self.height - 1); + let w = w.clamp(1, self.width - x); + let h = h.clamp(1, self.height - y); + if x == 0 && y == 0 && w == self.width && h == self.height { + return self; + } + let src_stride = self.stride() as usize; + let row = w as usize * 4; + let mut data = Vec::with_capacity(row * h as usize); + for r in 0..h as usize { + let start = (y as usize + r) * src_stride + x as usize * 4; + data.extend_from_slice(&self.data[start..start + row]); + } + Frame { + width: w, + height: h, + data, + } + } + + /// Build a frame from straight RGBA (consumes the buffer, converts in place). + pub fn from_rgba(img: image::RgbaImage) -> Frame { + let (width, height) = (img.width() as i32, img.height() as i32); + let mut data = img.into_raw(); + for px in data.chunks_exact_mut(4) { + let a = u16::from(px[3]); + let (r, g, b) = (px[0], px[1], px[2]); + if a == 255 { + px[0] = b; + px[2] = r; + } else { + px[0] = ((u16::from(b) * a + 127) / 255) as u8; + px[1] = ((u16::from(g) * a + 127) / 255) as u8; + px[2] = ((u16::from(r) * a + 127) / 255) as u8; + } + } + Frame { + width, + height, + data, + } + } +} + +/// Convert premultiplied BGRA rows (with `stride`) into a straight RGBA image. +pub fn bgra_to_rgba(data: &[u8], width: i32, height: i32, stride: i32) -> image::RgbaImage { + let mut out = Vec::with_capacity(width as usize * height as usize * 4); + for y in 0..height as usize { + let row = &data[y * stride as usize..y * stride as usize + width as usize * 4]; + for px in row.chunks_exact(4) { + let a = px[3]; + let un = |c: u8| -> u8 { + if a == 255 || a == 0 { + c + } else { + ((u16::from(c) * 255 + u16::from(a) / 2) / u16::from(a)).min(255) as u8 + } + }; + out.extend_from_slice(&[un(px[2]), un(px[1]), un(px[0]), a]); + } + } + image::RgbaImage::from_raw(width as u32, height as u32, out).unwrap_or_default() +} + +/// Grab the whole desktop. +pub fn grab_desktop() -> Result { + if crate::recorder::portal::is_wayland_session() { + portal() + .or_else(|e| { + log::warn!("portal screenshot failed ({e}); trying CLI tools"); + cli() + }) + .map_err(|e| format!("Could not capture the screen: {e}")) + } else { + x11().or_else(|e| { + log::warn!("X11 grab failed ({e}); trying CLI tools"); + cli() + }) + } +} + +fn x11() -> Result { + let (conn, screen_num) = x11rb::connect(None).map_err(|e| e.to_string())?; + let screen = &conn.setup().roots[screen_num]; + let (w, h) = (screen.width_in_pixels, screen.height_in_pixels); + let reply = conn + .get_image(ImageFormat::Z_PIXMAP, screen.root, 0, 0, w, h, !0) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let bpp = conn + .setup() + .pixmap_formats + .iter() + .find(|f| f.depth == reply.depth) + .map(|f| f.bits_per_pixel) + .unwrap_or(32); + if bpp != 32 || reply.data.len() < w as usize * h as usize * 4 { + return Err(format!( + "unsupported root format (depth {}, {bpp} bpp)", + reply.depth + )); + } + let msb = conn.setup().image_byte_order == ImageOrder::MSB_FIRST; + let mut data = reply.data; + for px in data.chunks_exact_mut(4) { + if msb { + px.reverse(); + } + px[3] = 255; + } + Ok(Frame { + width: i32::from(w), + height: i32::from(h), + data, + }) +} + +fn portal() -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + let uri = rt.block_on(async { + use ashpd::desktop::screenshot::Screenshot; + let response = Screenshot::request() + .interactive(false) + .modal(false) + .send() + .await + .map_err(|e| e.to_string())? + .response() + .map_err(|e| e.to_string())?; + Ok::<_, String>(response.uri().as_str().to_string()) + })?; + let path = PathBuf::from(percent_decode( + uri.strip_prefix("file://") + .ok_or("portal returned a non-file URI")?, + )); + // The portal saves into ~/Pictures; hide the file from the screenshot + // watcher straight away (dot-files are ignored), then delete it. + let hidden = path.with_file_name(format!(".rustcast-grab-{}.png", std::process::id())); + let src = if std::fs::rename(&path, &hidden).is_ok() { + hidden + } else { + path + }; + let frame = load(&src); + let _ = std::fs::remove_file(&src); + frame +} + +fn cli() -> Result { + let dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + let out = dir.join(format!("rustcast-grab-{}.png", std::process::id())); + let o = out.to_string_lossy().to_string(); + let tools: [(&str, Vec<&str>); 5] = [ + ("grim", vec![&o]), + ("gnome-screenshot", vec!["-f", &o]), + ("spectacle", vec!["-b", "-n", "-f", "-o", &o]), + ("scrot", vec!["-o", &o]), + ("import", vec!["-window", "root", &o]), + ]; + for (tool, args) in tools { + let mut cmd = Command::new(tool); + cmd.args(&args).stdout(Stdio::null()).stderr(Stdio::null()); + // These tools must talk to the real Wayland compositor when there is one. + if let Some(wd) = std::env::var_os("RUSTCAST_WAYLAND_DISPLAY") { + cmd.env("WAYLAND_DISPLAY", wd); + cmd.env_remove("GDK_BACKEND"); + } + if cmd.status().is_ok_and(|s| s.success()) && out.exists() { + let frame = load(&out); + let _ = std::fs::remove_file(&out); + return frame; + } + } + Err("no screenshot tool worked (install grim or gnome-screenshot)".to_string()) +} + +fn load(path: &Path) -> Result { + let img = image::open(path).map_err(|e| e.to_string())?; + Ok(Frame::from_rgba(img.into_rgba8())) +} + +fn percent_decode(s: &str) -> String { + let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8); + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) + { + out.push(hi << 4 | lo); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Visible top-level windows on the current workspace, top-most first, as +/// `(x, y, w, h)` in root (device) pixels, including their decorations. +/// Only meaningful on X11 — on Wayland XWayland only sees its own clients. +pub fn visible_windows() -> Vec<(i32, i32, i32, i32)> { + let Ok((conn, screen_num)) = x11rb::connect(None) else { + return Vec::new(); + }; + let root = conn.setup().roots[screen_num].root; + let atom = |name: &str| { + conn.intern_atom(false, name.as_bytes()) + .ok() + .and_then(|c| c.reply().ok()) + .map(|r| r.atom) + }; + let cardinals = |win, prop: Option, n: u32| -> Vec { + prop.and_then(|p| { + conn.get_property(false, win, p, AtomEnum::ANY, 0, n) + .ok()? + .reply() + .ok()? + .value32() + .map(|v| v.collect()) + }) + .unwrap_or_default() + }; + + let stacking = cardinals(root, atom("_NET_CLIENT_LIST_STACKING"), u32::MAX); + let current = cardinals(root, atom("_NET_CURRENT_DESKTOP"), 1) + .first() + .copied(); + let (wm_desktop, frame_ext, gtk_ext, wm_state, hidden) = ( + atom("_NET_WM_DESKTOP"), + atom("_NET_FRAME_EXTENTS"), + atom("_GTK_FRAME_EXTENTS"), + atom("_NET_WM_STATE"), + atom("_NET_WM_STATE_HIDDEN"), + ); + + let mut out = Vec::new(); + for &win in stacking.iter().rev() { + let viewable = conn + .get_window_attributes(win) + .ok() + .and_then(|c| c.reply().ok()) + .is_some_and(|a| a.map_state == MapState::VIEWABLE); + if !viewable { + continue; + } + if let (Some(cur), Some(&desk)) = (current, cardinals(win, wm_desktop, 1).first()) + && desk != cur + && desk != u32::MAX + { + continue; + } + if let Some(h) = hidden + && cardinals(win, wm_state, 64).contains(&h) + { + continue; + } + let Some(geo) = conn.get_geometry(win).ok().and_then(|c| c.reply().ok()) else { + continue; + }; + let Some(pos) = conn + .translate_coordinates(win, root, 0, 0) + .ok() + .and_then(|c| c.reply().ok()) + else { + continue; + }; + let (mut x, mut y) = (i32::from(pos.dst_x), i32::from(pos.dst_y)); + let (mut w, mut h) = (i32::from(geo.width), i32::from(geo.height)); + // Server-side decorations sit outside the client window… + if let [l, r, t, b] = cardinals(win, frame_ext, 4)[..] { + x -= l as i32; + y -= t as i32; + w += (l + r) as i32; + h += (t + b) as i32; + } + // …client-side shadows sit inside it. + if let [l, r, t, b] = cardinals(win, gtk_ext, 4)[..] { + x += l as i32; + y += t as i32; + w -= (l + r) as i32; + h -= (t + b) as i32; + } + if w > 8 && h > 8 { + out.push((x, y, w, h)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_decoding_handles_spaces_and_utf8() { + assert_eq!( + percent_decode("/home/a/Pictures/Screenshot%20from%202024.png"), + "/home/a/Pictures/Screenshot from 2024.png" + ); + assert_eq!(percent_decode("/tmp/%C3%A9.png"), "/tmp/é.png"); + assert_eq!(percent_decode("/tmp/100%"), "/tmp/100%"); + } + + #[test] + fn crop_copies_the_right_rows() { + let mut data = Vec::new(); + for i in 0..16u8 { + data.extend_from_slice(&[i, i, i, 255]); + } + let f = Frame { + width: 4, + height: 4, + data, + }; + let c = f.crop(1, 2, 2, 2); + assert_eq!((c.width, c.height), (2, 2)); + let firsts: Vec = c.data.chunks(4).map(|p| p[0]).collect(); + assert_eq!(firsts, vec![9, 10, 13, 14]); + } + + #[test] + fn rgba_round_trip_is_lossless_for_opaque_pixels() { + let img = + image::RgbaImage::from_raw(2, 1, vec![10, 20, 30, 255, 200, 100, 50, 255]).unwrap(); + let f = Frame::from_rgba(img.clone()); + assert_eq!(&f.data[..4], &[30, 20, 10, 255]); + let back = bgra_to_rgba(&f.data, 2, 1, 8); + assert_eq!(back, img); + } +} diff --git a/src/snap/mod.rs b/src/snap/mod.rs new file mode 100644 index 0000000..806a6c3 --- /dev/null +++ b/src/snap/mod.rs @@ -0,0 +1,346 @@ +//! Screenshot studio: capture, annotate, OCR, pin. +//! +//! Everything here runs in a short-lived subprocess (`rustcast --snap `) +//! so the resident launcher never holds a frozen screen, a GTK editor or an OCR +//! engine in memory — when the capture is done the process exits and all of it +//! is returned to the system. That matters on low-RAM machines. +//! +//! Modes: +//! - `area` freeze the screen, select a region (or click a window), annotate +//! - `window` same overlay, starting in window-pick mode +//! - `fullscreen` capture the whole monitor under the pointer immediately +//! - `quick` select a region, copy + save instantly (no annotation) +//! - `ocr` select a region, copy the text in it +//! - `edit ` open an existing image in the annotation editor +//! - `pin ` float an image above all windows +//! - `ocr-file ` read the text of an existing image +//! +//! `--delay ` shows a countdown before any capture mode. +//! +//! Results reach the clipboard through the running RustCast instance (see +//! [`copy_text`] / [`copy_image`]) so they outlive this process and show up in +//! the clipboard history. + +pub mod beautify; +pub mod editor; +pub mod grab; +pub mod ocr; +pub mod pin; +pub mod qr; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime}; + +use crate::config::{Config, ScreenshotConfig}; + +/// Spawn a screenshot subprocess, e.g. `spawn(&["area"])` or +/// `spawn(&["edit", "/path/shot.png"])`. +pub fn spawn(args: &[&str]) { + let Ok(exe) = std::env::current_exe() else { + log::warn!("cannot locate own exe to start a capture"); + return; + }; + let mut cmd = Command::new(exe); + cmd.arg("--snap").args(args); + // Same reasoning as the thumbnail overlay: run on X11/XWayland so the + // overlay can cover a whole monitor at a fixed position. The real Wayland + // display stays available as RUSTCAST_WAYLAND_DISPLAY (for the portal). + cmd.env("GDK_BACKEND", "x11"); + cmd.env_remove("WAYLAND_DISPLAY"); + if let Err(e) = cmd.spawn() { + log::warn!("failed to start capture: {e}"); + } +} + +/// Entry point of the `--snap` subprocess. `args` are the arguments after `--snap`. +pub fn run(args: &[String]) { + force_x11_backend(); + + let mut mode = String::from("area"); + let mut path: Option = None; + let mut delay = 0u64; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--delay" => delay = it.next().and_then(|d| d.parse().ok()).unwrap_or(0), + m if !m.starts_with("--") && path.is_none() && mode_takes_path(&mode) => { + path = Some(PathBuf::from(m)); + } + m if !m.starts_with("--") => { + mode = m.to_string(); + } + _ => {} + } + } + + if gtk::init().is_err() { + eprintln!("rustcast snap: GTK init failed"); + return; + } + let cfg = load_config().screenshot; + + match mode.as_str() { + "edit" | "pin" | "ocr-file" => { + let Some(path) = path else { + eprintln!("rustcast snap: {mode} needs an image path"); + return; + }; + match mode.as_str() { + "edit" => editor::run_file(&path, cfg), + "pin" => pin::run(&path), + _ => ocr::run_file(&path, cfg), + } + } + "area" | "window" | "quick" | "ocr" | "fullscreen" => { + if delay > 0 { + countdown(delay); + } + let flavor = match mode.as_str() { + "window" => editor::Flavor::Window, + "quick" => editor::Flavor::Quick, + "ocr" => editor::Flavor::Ocr, + "fullscreen" => editor::Flavor::Fullscreen, + _ => editor::Flavor::Area, + }; + editor::run_capture(flavor, cfg); + } + other => eprintln!("rustcast snap: unknown mode '{other}'"), + } +} + +fn mode_takes_path(mode: &str) -> bool { + matches!(mode, "edit" | "pin" | "ocr-file") +} + +/// When started straight from a Wayland terminal, move GTK onto XWayland the +/// same way `main` does for the launcher (window positioning needs X11). +fn force_x11_backend() { + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WAYLAND_DISPLAY").is_some() { + // SAFETY: called first thing in the subprocess, before any threads exist. + unsafe { + if let Some(wd) = std::env::var_os("WAYLAND_DISPLAY") { + std::env::set_var("RUSTCAST_WAYLAND_DISPLAY", wd); + } + std::env::remove_var("WAYLAND_DISPLAY"); + std::env::set_var("GDK_BACKEND", "x11"); + } + } +} + +pub fn load_config() -> Config { + let home = std::env::var("HOME").unwrap_or_default(); + std::fs::read_to_string(format!("{home}/.config/rustcast/config.toml")) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() +} + +/// A small "3… 2… 1…" pill before a delayed capture. Esc cancels (exits). +fn countdown(secs: u64) { + use gtk::glib; + use gtk::prelude::*; + + let window = gtk::Window::new(gtk::WindowType::Popup); + window.set_keep_above(true); + window.set_app_paintable(true); + if let Some(visual) = WidgetExt::screen(&window).and_then(|s| s.rgba_visual()) { + window.set_visual(Some(&visual)); + } + let size = 120; + window.set_default_size(size, size); + window.set_position(gtk::WindowPosition::Center); + + let remaining = std::rc::Rc::new(std::cell::Cell::new(secs)); + { + let remaining = remaining.clone(); + window.connect_draw(move |_, cr| { + cr.set_operator(gtk::cairo::Operator::Source); + cr.set_source_rgba(0.0, 0.0, 0.0, 0.0); + let _ = cr.paint(); + cr.set_operator(gtk::cairo::Operator::Over); + cr.arc(60.0, 60.0, 54.0, 0.0, std::f64::consts::TAU); + cr.set_source_rgba(0.08, 0.08, 0.1, 0.85); + let _ = cr.fill(); + let layout = pangocairo::functions::create_layout(cr); + let mut font = gtk::pango::FontDescription::from_string("Sans Bold"); + font.set_absolute_size(52.0 * f64::from(gtk::pango::SCALE)); + layout.set_font_description(Some(&font)); + layout.set_text(&remaining.get().to_string()); + let (w, h) = layout.pixel_size(); + cr.move_to(60.0 - f64::from(w) / 2.0, 60.0 - f64::from(h) / 2.0); + cr.set_source_rgb(1.0, 1.0, 1.0); + pangocairo::functions::show_layout(cr, &layout); + glib::Propagation::Stop + }); + } + window.show_all(); + + let main_loop = glib::MainLoop::new(None, false); + { + let main_loop = main_loop.clone(); + let window = window.clone(); + glib::timeout_add_local(Duration::from_secs(1), move || { + let left = remaining.get().saturating_sub(1); + remaining.set(left); + if left == 0 { + window.close(); + main_loop.quit(); + return glib::ControlFlow::Break; + } + window.queue_draw(); + glib::ControlFlow::Continue + }); + } + main_loop.run(); + // Let the compositor remove the pill before the screen is grabbed. + flush_gtk(); + std::thread::sleep(Duration::from_millis(180)); +} + +/// Run pending GTK events (e.g. so a hidden window is really gone). +pub fn flush_gtk() { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } +} + +fn timestamp_name(prefix: &str, ext: &str) -> String { + let ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{prefix}-{ms}.{ext}") +} + +/// Encode `img` according to the configured format and write it into `dir`. +pub fn write_image(img: &image::RgbaImage, dir: &Path, cfg: &ScreenshotConfig) -> Option { + std::fs::create_dir_all(dir).ok()?; + let ext = cfg.extension(); + let path = dir.join(timestamp_name("rustcast", ext)); + write_image_to(img, &path, cfg).then_some(path) +} + +/// Encode `img` to `path`, picking the format from the extension. +pub fn write_image_to(img: &image::RgbaImage, path: &Path, cfg: &ScreenshotConfig) -> bool { + // Write to a dot-file first and rename, so the screenshot watcher never + // reads a half-written image. + let Some(name) = path.file_name() else { + return false; + }; + let tmp = path.with_file_name(format!(".{}.part", name.to_string_lossy())); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let result = (|| -> image::ImageResult<()> { + let file = std::io::BufWriter::new(std::fs::File::create(&tmp)?); + match ext.as_str() { + "jpg" | "jpeg" => { + let rgb = image::DynamicImage::ImageRgba8(img.clone()).to_rgb8(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality( + file, + cfg.jpeg_quality.clamp(10, 100), + ); + rgb.write_with_encoder(enc) + } + "webp" => img.write_with_encoder(image::codecs::webp::WebPEncoder::new_lossless(file)), + _ => img.write_with_encoder(image::codecs::png::PngEncoder::new_with_quality( + file, + image::codecs::png::CompressionType::Fast, + image::codecs::png::FilterType::Adaptive, + )), + } + })(); + match result.and_then(|_| std::fs::rename(&tmp, path).map_err(Into::into)) { + Ok(()) => true, + Err(e) => { + log::warn!("could not write {}: {e}", path.display()); + let _ = std::fs::remove_file(&tmp); + false + } + } +} + +/// Put text on the clipboard. Prefers the running RustCast instance (so the +/// text survives this process and lands in clipboard history); otherwise owns +/// the clipboard here and asks a clipboard manager to keep it. +pub fn copy_text(text: &str) { + let url = format!( + "rustcast://snap-copy?text={}", + url::form_urlencoded::byte_serialize(text.as_bytes()).collect::() + ); + if crate::platform::urlscheme::forward_if_running(&url) { + return; + } + let clipboard = gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD); + clipboard.set_text(text); + clipboard.store(); +} + +/// Put the image stored at `path` on the clipboard (see [`copy_text`]). +pub fn copy_image(path: &Path) { + let url = format!( + "rustcast://snap-copy?image={}", + url::form_urlencoded::byte_serialize(path.to_string_lossy().as_bytes()).collect::() + ); + if crate::platform::urlscheme::forward_if_running(&url) { + return; + } + if let Ok(pixbuf) = gtk::gdk_pixbuf::Pixbuf::from_file(path) { + let clipboard = gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD); + clipboard.set_image(&pixbuf); + clipboard.store(); + } +} + +/// Main-process side of [`copy_text`] / [`copy_image`]: handle a +/// `rustcast://snap-copy?...` URL. +pub fn handle_copy_url(url: &url::Url) { + for (key, value) in url.query_pairs() { + match key.as_ref() { + "text" => { + if let Ok(mut cb) = arboard::Clipboard::new() { + cb.set_text(value.into_owned()).ok(); + } + } + "image" => { + let path = PathBuf::from(value.as_ref()); + let Ok(img) = image::open(&path) else { + continue; + }; + let rgba = img.to_rgba8(); + let data = arboard::ImageData { + width: rgba.width() as usize, + height: rgba.height() as usize, + bytes: std::borrow::Cow::Owned(rgba.into_raw()), + }; + if let Ok(mut cb) = arboard::Clipboard::new() { + cb.set_image(data).ok(); + } + } + _ => {} + } + } +} + +/// Wait (max ~1.5 s) so a clipboard manager can take over what this process +/// owns, when the main instance was not reachable. +pub fn linger_for_clipboard() { + let until = std::time::Instant::now() + Duration::from_millis(1500); + while std::time::Instant::now() < until { + flush_gtk(); + std::thread::sleep(Duration::from_millis(30)); + } +} + +/// Is a RustCast instance running to receive clipboard handoffs? +pub fn main_instance_running() -> bool { + crate::platform::urlscheme::is_running() +} + +/// Open a URL with the desktop's default handler. +pub fn open_url(url: &str) { + let _ = Command::new("xdg-open").arg(url).spawn(); +} diff --git a/src/snap/ocr.rs b/src/snap/ocr.rs new file mode 100644 index 0000000..a9e7174 --- /dev/null +++ b/src/snap/ocr.rs @@ -0,0 +1,506 @@ +//! OCR: read the text inside a screenshot. +//! +//! Engine: the system `tesseract` binary, run as a short-lived child process. +//! Nothing is loaded into RustCast itself — the engine and its language model +//! only occupy memory for the fraction of a second the recognition takes and +//! are released as soon as it exits. That keeps the launcher's footprint at +//! zero for this feature, which is what low-end machines need. +//! +//! The crop is prepared so Tesseract does well on screen text (which is far +//! smaller and lower-DPI than the scans it is tuned for): +//! - converted to 8-bit grayscale (¼ of the RGBA size) and sent as PGM over a +//! pipe — no PNG encoding, no temporary files; +//! - light-on-dark UI (dark themes, terminals) is inverted to dark-on-light; +//! - small crops are upscaled 2–3× so glyphs reach a size the model expects, +//! with a cap so large crops never balloon in memory; +//! - a white margin is added (Tesseract misses text touching the border); +//! - `OMP_THREAD_LIMIT=1`: on small images threads only add memory and +//! start-up cost. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use gtk::glib; +use gtk::prelude::*; +use image::{GrayImage, RgbaImage}; + +use crate::config::ScreenshotConfig; + +/// Largest image (in pixels) handed to the engine after upscaling. +const MAX_PIXELS: u32 = 6_000_000; +const MARGIN: u32 = 12; + +pub fn tesseract_installed() -> bool { + Command::new("tesseract") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +/// Installed Tesseract language packs (e.g. `["eng", "hin", "osd"]`). +pub fn installed_languages() -> Vec { + Command::new("tesseract") + .arg("--list-langs") + .stderr(Stdio::null()) + .output() + .ok() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .skip(1) + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && l != "osd") + .collect() + }) + .unwrap_or_default() +} + +/// Keep only the requested languages that are installed; fall back to English +/// (or whatever is installed) so a typo in the config never breaks OCR. +fn pick_languages(requested: &str, installed: &[String]) -> String { + let ok: Vec<&str> = requested + .split('+') + .map(str::trim) + .filter(|l| installed.iter().any(|i| i == l)) + .collect(); + if !ok.is_empty() { + return ok.join("+"); + } + if installed.iter().any(|l| l == "eng") { + return "eng".to_string(); + } + installed + .first() + .cloned() + .unwrap_or_else(|| "eng".to_string()) +} + +/// Grayscale, auto-invert, upscale and pad an image for recognition. +pub fn prepare(img: &RgbaImage) -> GrayImage { + let (w, h) = img.dimensions(); + let mut gray = GrayImage::new(w, h); + let mut sum: u64 = 0; + for (src, dst) in img.pixels().zip(gray.pixels_mut()) { + let [r, g, b, _] = src.0; + let l = ((u32::from(r) * 77 + u32::from(g) * 150 + u32::from(b) * 29) >> 8) as u8; + sum += u64::from(l); + dst.0[0] = l; + } + let mean = sum / u64::from((w * h).max(1)); + if mean < 110 { + for p in gray.pixels_mut() { + p.0[0] = 255 - p.0[0]; + } + } + + // Screen text is ~10–16 px tall; Tesseract is happiest around 30+ px. + let mut factor = if h <= 120 { 3 } else { 2 }; + while factor > 1 && (w * factor) * (h * factor) > MAX_PIXELS { + factor -= 1; + } + let scaled = if factor > 1 { + image::imageops::resize( + &gray, + w * factor, + h * factor, + image::imageops::FilterType::Triangle, + ) + } else { + gray + }; + + let (sw, sh) = scaled.dimensions(); + let mut padded = GrayImage::from_pixel(sw + 2 * MARGIN, sh + 2 * MARGIN, image::Luma([255])); + image::imageops::replace(&mut padded, &scaled, i64::from(MARGIN), i64::from(MARGIN)); + padded +} + +fn to_pgm(img: &GrayImage) -> Vec { + let (w, h) = img.dimensions(); + let mut out = format!("P5\n{w} {h}\n255\n").into_bytes(); + out.extend_from_slice(img.as_raw()); + out +} + +/// Recognise the text in `img`. `languages` is a Tesseract language string +/// such as `eng` or `eng+hin`. +pub fn recognize(img: &RgbaImage, languages: &str) -> Result { + if !tesseract_installed() { + return Err(missing_engine_message()); + } + let langs = pick_languages(languages, &installed_languages()); + let pgm = to_pgm(&prepare(img)); + + let run = |psm: &str| -> Result { + let mut child = Command::new("tesseract") + .args([ + "stdin", "stdout", "-l", &langs, "--psm", psm, "--dpi", "300", + ]) + .env("OMP_THREAD_LIMIT", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(&pgm).map_err(|e| e.to_string())?; + } + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(clean(&String::from_utf8_lossy(&out.stdout))) + }; + + // Automatic page segmentation handles columns and mixed layouts; a single + // text block is the better guess when that finds nothing (tiny crops). + let text = run("3")?; + if text.is_empty() { run("6") } else { Ok(text) } +} + +/// Tidy Tesseract output: drop form feeds, trailing spaces and runs of blank lines. +fn clean(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut blank = 0; + for line in raw.replace('\u{c}', "").lines() { + let line = line.trim_end(); + if line.trim().is_empty() { + blank += 1; + continue; + } + if !out.is_empty() { + out.push_str(if blank > 0 { "\n\n" } else { "\n" }); + } + blank = 0; + out.push_str(line); + } + out +} + +pub fn missing_engine_message() -> String { + "Text recognition needs Tesseract, which is not installed.\n\n\ + Ubuntu / Debian: sudo apt install tesseract-ocr\n\ + Fedora: sudo dnf install tesseract\n\ + Arch: sudo pacman -S tesseract tesseract-data-eng\n\n\ + Extra languages, e.g. Hindi: sudo apt install tesseract-ocr-hin\n\ + then set ocr_languages = \"eng+hin\" under [screenshot] in the config." + .to_string() +} + +/// `ocr-file` mode: read the text of an image on disk. +pub fn run_file(path: &Path, cfg: ScreenshotConfig) { + match image::open(path) { + Ok(img) => run_on_image(img.into_rgba8(), cfg), + Err(e) => eprintln!("rustcast ocr: cannot open {}: {e}", path.display()), + } +} + +/// Recognise `img` off the GTK thread, copy the result and show it. +/// Blocks (runs a GTK main loop) until the result window is closed. +pub fn run_on_image(img: RgbaImage, cfg: ScreenshotConfig) { + let (tx, rx) = mpsc::channel(); + let langs = cfg.ocr_languages.clone(); + std::thread::spawn(move || { + let codes = super::qr::decode(&img); + let text = recognize(&img, &langs); + drop(img); + let _ = tx.send((text, codes)); + }); + + let spinner = busy_window("Reading text…"); + let main_loop = glib::MainLoop::new(None, false); + let result = std::rc::Rc::new(std::cell::RefCell::new(None)); + { + let main_loop = main_loop.clone(); + let result = result.clone(); + glib::timeout_add_local(Duration::from_millis(40), move || match rx.try_recv() { + Ok(r) => { + *result.borrow_mut() = Some(r); + main_loop.quit(); + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => { + main_loop.quit(); + glib::ControlFlow::Break + } + }); + } + main_loop.run(); + spinner.close(); + + let Some((text, codes)) = result.borrow_mut().take() else { + return; + }; + let (text, error) = match text { + Ok(t) => (t, None), + Err(e) => (String::new(), Some(e)), + }; + if !text.is_empty() { + super::copy_text(&text); + } else if let Some(first) = codes.first() { + super::copy_text(first); + } + show_result(text, codes, error, cfg); +} + +/// A tiny "working…" pill in the middle of the screen. +pub fn busy_window(label: &str) -> gtk::Window { + let window = gtk::Window::new(gtk::WindowType::Popup); + window.set_position(gtk::WindowPosition::Center); + window.set_keep_above(true); + let lbl = gtk::Label::new(Some(label)); + lbl.set_margin_top(14); + lbl.set_margin_bottom(14); + lbl.set_margin_start(22); + lbl.set_margin_end(22); + window.add(&lbl); + window.show_all(); + super::flush_gtk(); + window +} + +/// The result window: editable recognised text, QR codes, Copy / Translate / Search. +fn show_result(text: String, codes: Vec, error: Option, cfg: ScreenshotConfig) { + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("Text from screen — RustCast"); + window.set_default_size(560, 380); + window.set_position(gtk::WindowPosition::Center); + window.set_keep_above(true); + window.set_type_hint(gtk::gdk::WindowTypeHint::Dialog); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + root.set_margin_top(12); + root.set_margin_bottom(12); + root.set_margin_start(12); + root.set_margin_end(12); + + let status = gtk::Label::new(None); + status.set_xalign(0.0); + status.set_line_wrap(true); + let chars = text.chars().count(); + status.set_markup(&match (&error, chars, codes.is_empty()) { + (Some(_), _, true) => "Couldn't read text".to_string(), + (_, 0, true) => "No text found in the selection".to_string(), + (_, 0, false) => "QR code copied to the clipboard".to_string(), + _ => format!("Copied {chars} characters to the clipboard"), + }); + root.pack_start(&status, false, false, 0); + + for code in &codes { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let label = gtk::Label::new(Some(&format!("QR: {code}"))); + label.set_xalign(0.0); + label.set_ellipsize(gtk::pango::EllipsizeMode::End); + label.set_selectable(true); + row.pack_start(&label, true, true, 0); + let copy = gtk::Button::with_label("Copy"); + { + let code = code.clone(); + copy.connect_clicked(move |_| super::copy_text(&code)); + } + row.pack_end(©, false, false, 0); + if code.starts_with("http://") || code.starts_with("https://") { + let open = gtk::Button::with_label("Open"); + let code = code.clone(); + open.connect_clicked(move |_| super::open_url(&code)); + row.pack_end(&open, false, false, 0); + } + root.pack_start(&row, false, false, 0); + } + + let view = gtk::TextView::new(); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_left_margin(8); + view.set_right_margin(8); + view.set_top_margin(6); + view.set_bottom_margin(6); + if let Some(buffer) = view.buffer() { + buffer.set_text(error.as_deref().unwrap_or(&text)); + } + let scroll = gtk::ScrolledWindow::new(None::<>k::Adjustment>, None::<>k::Adjustment>); + scroll.set_shadow_type(gtk::ShadowType::In); + scroll.add(&view); + root.pack_start(&scroll, true, true, 0); + + let current_text = { + let view = view.clone(); + move || -> String { + view.buffer() + .and_then(|b| b.text(&b.start_iter(), &b.end_iter(), false)) + .map(|s| s.to_string()) + .unwrap_or_default() + } + }; + + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let target = cfg.translate_target(); + let copy = gtk::Button::with_label("Copy"); + let translate = gtk::Button::with_label(&format!("Translate → {target}")); + let search = gtk::Button::with_label("Search"); + let close = gtk::Button::with_label("Close"); + buttons.pack_start(©, false, false, 0); + buttons.pack_start(&translate, false, false, 0); + buttons.pack_start(&search, false, false, 0); + buttons.pack_end(&close, false, false, 0); + root.pack_start(&buttons, false, false, 0); + window.add(&root); + + { + let current_text = current_text.clone(); + let status = status.clone(); + copy.connect_clicked(move |_| { + super::copy_text(¤t_text()); + status.set_markup("Copied to the clipboard"); + }); + } + { + let current_text = current_text.clone(); + let status = status.clone(); + let view = view.clone(); + translate.connect_clicked(move |btn| { + let text = current_text(); + if text.trim().is_empty() { + return; + } + // Offline-friendly path: translate-shell prints the translation + // in-place. Without it, hand off to the browser. + if !has_translate_shell() { + let url = format!( + "https://translate.google.com/?sl=auto&tl={}&op=translate&text={}", + target, + url::form_urlencoded::byte_serialize( + text.chars().take(5000).collect::().as_bytes() + ) + .collect::() + ); + super::open_url(&url); + return; + } + btn.set_sensitive(false); + status.set_markup("Translating…"); + let (tx, rx) = mpsc::channel(); + let target = target.clone(); + std::thread::spawn(move || { + let _ = tx.send(translate_shell(&text, &target)); + }); + let (status, view, btn) = (status.clone(), view.clone(), btn.clone()); + glib::timeout_add_local(Duration::from_millis(60), move || match rx.try_recv() { + Ok(result) => { + btn.set_sensitive(true); + match result { + Ok(t) => { + if let Some(b) = view.buffer() { + b.set_text(&t); + } + super::copy_text(&t); + status.set_markup("Translated and copied to the clipboard"); + } + Err(e) => status.set_text(&format!("Translation failed: {e}")), + } + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(_) => glib::ControlFlow::Break, + }); + }); + } + { + let current_text = current_text.clone(); + let search_url = super::load_config().search_url; + search.connect_clicked(move |_| { + let q: String = current_text().chars().take(300).collect(); + let q = url::form_urlencoded::byte_serialize(q.trim().as_bytes()).collect::(); + super::open_url(&search_url.replace("%s", &q)); + }); + } + { + let window = window.clone(); + close.connect_clicked(move |_| window.close()); + } + window.connect_key_press_event(|w, ev| { + if ev.keyval() == gtk::gdk::keys::constants::Escape { + w.close(); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + + window.connect_destroy(|_| gtk::main_quit()); + window.show_all(); + window.present(); + gtk::main(); + if !super::main_instance_running() { + super::linger_for_clipboard(); + } +} + +fn has_translate_shell() -> bool { + Command::new("trans") + .arg("-V") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +fn translate_shell(text: &str, target: &str) -> Result { + let out = Command::new("trans") + .args([ + "-b", + "-no-ansi", + "-no-autocorrect", + &format!(":{target}"), + text, + ]) + .output() + .map_err(|e| e.to_string())?; + let t = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if t.is_empty() { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } else { + Ok(t) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_selection_falls_back_gracefully() { + let installed = vec!["eng".to_string(), "hin".to_string()]; + assert_eq!(pick_languages("eng+hin", &installed), "eng+hin"); + assert_eq!(pick_languages("deu+hin", &installed), "hin"); + assert_eq!(pick_languages("xyz", &installed), "eng"); + assert_eq!(pick_languages("xyz", &["fra".to_string()]), "fra"); + } + + #[test] + fn output_is_tidied() { + assert_eq!(clean("Hello \n\n\n\nWorld\n\u{c}"), "Hello\n\nWorld"); + assert_eq!(clean("a\nb\n"), "a\nb"); + assert_eq!(clean(" \n\n"), ""); + } + + #[test] + fn dark_text_backgrounds_are_inverted_and_small_crops_upscaled() { + // White text on black → mean is dark → inverted to mostly white. + let img = RgbaImage::from_pixel(40, 20, image::Rgba([0, 0, 0, 255])); + let g = prepare(&img); + assert_eq!(g.dimensions(), (40 * 3 + 2 * MARGIN, 20 * 3 + 2 * MARGIN)); + assert!(g.pixels().all(|p| p.0[0] == 255)); + } + + #[test] + fn huge_crops_are_not_upscaled() { + let img = RgbaImage::from_pixel(3000, 2000, image::Rgba([255, 255, 255, 255])); + let g = prepare(&img); + assert_eq!(g.dimensions(), (3000 + 2 * MARGIN, 2000 + 2 * MARGIN)); + } +} diff --git a/src/snap/pin.rs b/src/snap/pin.rs new file mode 100644 index 0000000..c30ff08 --- /dev/null +++ b/src/snap/pin.rs @@ -0,0 +1,154 @@ +//! Pin an image to the screen: a borderless, always-on-top floating window. +//! +//! - drag to move · scroll to zoom · Ctrl+scroll for opacity +//! - double-click or Esc to close · right-click for Copy / Save / Edit / Close + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use gtk::gdk; +use gtk::gdk_pixbuf::{InterpType, Pixbuf}; +use gtk::glib; +use gtk::prelude::*; + +pub fn run(path: &Path) { + let Ok(original) = Pixbuf::from_file(path) else { + eprintln!("rustcast pin: cannot load {}", path.display()); + return; + }; + let path = path.to_path_buf(); + let (iw, ih) = (f64::from(original.width()), f64::from(original.height())); + + // Start at a comfortable size: at most half the monitor. + let (mw, mh) = gdk::Display::default() + .and_then(|d| d.primary_monitor().or_else(|| d.monitor(0))) + .map(|m| { + ( + f64::from(m.geometry().width()), + f64::from(m.geometry().height()), + ) + }) + .unwrap_or((1920.0, 1080.0)); + let zoom = Rc::new(Cell::new((mw * 0.5 / iw).min(mh * 0.5 / ih).min(1.0))); + let opacity = Rc::new(Cell::new(1.0f64)); + + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("Pinned — RustCast"); + window.set_decorated(false); + window.set_keep_above(true); + window.set_skip_taskbar_hint(true); + window.set_type_hint(gdk::WindowTypeHint::Utility); + window.stick(); + + let image = gtk::Image::new(); + let evbox = gtk::EventBox::new(); + evbox.add(&image); + window.add(&evbox); + + let apply_zoom = { + let (image, window, original, zoom) = ( + image.clone(), + window.clone(), + original.clone(), + zoom.clone(), + ); + move || { + let z = zoom.get(); + let (w, h) = (((iw * z) as i32).max(24), ((ih * z) as i32).max(24)); + if let Some(scaled) = original.scale_simple(w, h, InterpType::Bilinear) { + image.set_from_pixbuf(Some(&scaled)); + } + window.resize(w, h); + } + }; + apply_zoom(); + + evbox.add_events(gdk::EventMask::SCROLL_MASK | gdk::EventMask::SMOOTH_SCROLL_MASK); + { + let (zoom, opacity, window) = (zoom.clone(), opacity.clone(), window.clone()); + let apply_zoom = apply_zoom.clone(); + evbox.connect_scroll_event(move |_, ev| { + let up = match ev.direction() { + gdk::ScrollDirection::Up => true, + gdk::ScrollDirection::Down => false, + _ => ev.delta().1 < 0.0, + }; + if ev.state().contains(gdk::ModifierType::CONTROL_MASK) { + let o = (opacity.get() + if up { 0.1 } else { -0.1 }).clamp(0.2, 1.0); + opacity.set(o); + window.set_opacity(o); + } else { + let z = (zoom.get() * if up { 1.1 } else { 1.0 / 1.1 }).clamp(0.05, 8.0); + zoom.set(z); + apply_zoom(); + } + glib::Propagation::Stop + }); + } + + { + let window = window.clone(); + let path = path.clone(); + evbox.connect_button_press_event(move |_, ev| { + match (ev.button(), ev.event_type()) { + (1, gdk::EventType::DoubleButtonPress) => window.close(), + (1, _) => { + let (x, y) = ev.root(); + window.begin_move_drag(1, x as i32, y as i32, ev.time()); + } + (3, _) => context_menu(&window, &path, ev), + _ => {} + } + glib::Propagation::Stop + }); + } + + window.connect_key_press_event(|w, ev| { + if ev.keyval() == gdk::keys::constants::Escape { + w.close(); + } + glib::Propagation::Proceed + }); + window.connect_destroy(|_| gtk::main_quit()); + window.show_all(); + window.present(); + gtk::main(); +} + +fn context_menu(window: >k::Window, path: &Path, ev: &gdk::EventButton) { + let menu = gtk::Menu::new(); + let add = |label: &str, f: Box| { + let item = gtk::MenuItem::with_label(label); + item.connect_activate(move |_| f()); + menu.append(&item); + }; + let p: PathBuf = path.to_path_buf(); + add("Copy", { + let p = p.clone(); + Box::new(move || super::copy_image(&p)) + }); + add("Annotate…", { + let p = p.clone(); + Box::new(move || super::spawn(&["edit", &p.to_string_lossy()])) + }); + add("Copy Text (OCR)", { + let p = p.clone(); + Box::new(move || super::spawn(&["ocr-file", &p.to_string_lossy()])) + }); + add("Show in Folder", { + let p = p.clone(); + Box::new(move || { + if let Some(dir) = p.parent() { + super::open_url(&dir.to_string_lossy()); + } + }) + }); + menu.append(>k::SeparatorMenuItem::new()); + add("Close", { + let window = window.clone(); + Box::new(move || window.close()) + }); + menu.show_all(); + menu.popup_at_pointer(Some(ev)); +} diff --git a/src/snap/qr.rs b/src/snap/qr.rs new file mode 100644 index 0000000..959cc9d --- /dev/null +++ b/src/snap/qr.rs @@ -0,0 +1,34 @@ +//! QR code reading for captures (pure Rust, no extra system libraries). + +use image::RgbaImage; + +/// Decode every QR code found in `img`. Returns their contents. +pub fn decode(img: &RgbaImage) -> Vec { + let (w, h) = img.dimensions(); + if w < 21 || h < 21 { + return Vec::new(); + } + let mut prepared = + rqrr::PreparedImage::prepare_from_greyscale(w as usize, h as usize, |x, y| { + let [r, g, b, _] = img.get_pixel(x as u32, y as u32).0; + ((u32::from(r) * 77 + u32::from(g) * 150 + u32::from(b) * 29) >> 8) as u8 + }); + prepared + .detect_grids() + .into_iter() + .filter_map(|grid| grid.decode().ok().map(|(_, content)| content)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_images_have_no_codes() { + let img = RgbaImage::from_pixel(100, 100, image::Rgba([255, 255, 255, 255])); + assert!(decode(&img).is_empty()); + let tiny = RgbaImage::from_pixel(5, 5, image::Rgba([0, 0, 0, 255])); + assert!(decode(&tiny).is_empty()); + } +}