diff --git a/README.md b/README.md index 2c27e9bf..0c509e28 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Full-screen saves, active-window grabs, region capture - Copy to clipboard or save to file - Uses `grim`, `slurp`, `wl-clipboard` (installed automatically by deb/rpm/AUR packages; fallback: xdg-desktop-portal) -- Copy text from screen (OCR): drag a region of the shown desktop and get its text on the clipboard (needs `tesseract`; no default shortcut) +- Copy text from screen (OCR): Ctrl+Shift+X, then drag a region of the shown desktop — or Ctrl+A for all of it — and get its text on the clipboard (needs `tesseract`) ### Sessions and persistence - Session persistence is enabled by default for boards, undo/redo history, and tool state @@ -552,10 +552,13 @@ sudo dnf install wl-clipboard grim slurp # Fedora ### Copy text from screen (OCR) -`Copy text from screen` recognizes the text in a dragged screen region and copies -it to the clipboard. It is optional: the action has no default shortcut and its -toolbar button is hidden until you turn it on. Install Tesseract and the language -data you configure in `[capture].ocr_languages` (default `eng`): +`Copy text from screen` (Ctrl+Shift+X) recognizes the text in a +dragged screen region and copies it to the clipboard; Ctrl+A inside +the selector reads the whole screen instead. A band sweeps the region while +Tesseract runs, and a card reports the outcome — never the recognized text, +which goes only to the clipboard. Its toolbar button is hidden until you turn it +on. Install Tesseract and the language data you configure in +`[capture].ocr_languages` (default `eng`): An active OCR selection cancels if the displayed screen image changes, so the selected coordinates can never be applied to replacement freeze or zoom pixels. diff --git a/config.example.toml b/config.example.toml index 7df456b6..2899cf41 100644 --- a/config.example.toml +++ b/config.example.toml @@ -293,8 +293,8 @@ export_all_boards_pdf_file = [] open_capture_folder = ["Ctrl+Alt+O"] # Select a screen region and copy the text recognized in it (needs Tesseract). -# Unbound by default: "O" is already the orange quick color. -copy_text_from_screen = [] +# Ctrl+A inside the selector reads the whole screen instead. +copy_text_from_screen = ["Ctrl+Shift+X"] # Toggle frozen mode toggle_frozen_mode = ["Ctrl+Shift+F"] diff --git a/configurator/src/app/update/shortcuts/tests.rs b/configurator/src/app/update/shortcuts/tests.rs index cab17a62..2c94c391 100644 --- a/configurator/src/app/update/shortcuts/tests.rs +++ b/configurator/src/app/update/shortcuts/tests.rs @@ -672,10 +672,10 @@ fn conflict_review_queue_arms_the_next_conflict_after_replace() { app.is_loading = false; app.draft .keybindings - .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); app.draft .keybindings - .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); app.draft .keybindings .set(KeybindingField::Undo, "Ctrl+Alt+Shift+Y".to_string()); @@ -713,10 +713,10 @@ fn conflict_review_cancel_stops_the_queue() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft .keybindings - .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); app.draft .keybindings - .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); let _ = app.handle_shortcut_conflict_review_started(); let _ = app.handle_shortcut_conflict_canceled(); assert!(!app.shortcut_conflict_review); @@ -728,10 +728,10 @@ fn jump_to_conflict_selects_the_other_claimant() { let (mut app, _effects) = ConfiguratorApp::new_app(); app.draft .keybindings - .set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); app.draft .keybindings - .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + .set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); let _ = app.handle_shortcut_conflict_review_started(); let jump = app .pending_shortcut_conflict diff --git a/configurator/src/models/keybindings/conflicts.rs b/configurator/src/models/keybindings/conflicts.rs index b9eb7209..802629ea 100644 --- a/configurator/src/models/keybindings/conflicts.rs +++ b/configurator/src/models/keybindings/conflicts.rs @@ -389,11 +389,20 @@ mod tests { #[test] fn conflict_lookup_returns_all_claimants() { + // This and the other generic multi-claimant fixtures use a chord no + // shipped default claims. Asserted rather than assumed: a future default + // taking `Ctrl+Shift+Q` should fail here saying why, not as an off-by-one + // somewhere else. + let binding = Shortcut::parse("Ctrl+Shift+Q").expect("parses"); + assert!( + claimants_for(&draft(), &binding).is_empty(), + "the fixture chord must stay unbound by default" + ); + let mut draft = draft(); - draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); - draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); - draft.set(KeybindingField::Undo, "ctrl+shift+x".to_string()); - let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses"); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); + draft.set(KeybindingField::Undo, "ctrl+shift+q".to_string()); let claimants = claimants_for(&draft, &binding); let fields: Vec<_> = claimants.iter().map(|claim| claim.field).collect(); assert!(fields.contains(&Some(KeybindingField::ClearCanvas))); @@ -431,10 +440,10 @@ mod tests { let mut draft = draft(); draft.set( KeybindingField::ToggleHelp, - "F10, F1, Ctrl+Shift+X".to_string(), + "F10, F1, Ctrl+Shift+Q".to_string(), ); - draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+X".to_string()); - let binding = Shortcut::parse("Ctrl+Shift+X").expect("parses"); + draft.set(KeybindingField::Undo, "Ctrl+Z, Ctrl+Shift+Q".to_string()); + let binding = Shortcut::parse("Ctrl+Shift+Q").expect("parses"); let claimants = other_claimants(&draft, KeybindingField::ClearCanvas, &binding); apply_recorded_replace( &mut draft, @@ -450,7 +459,7 @@ mod tests { assert_eq!(draft.value_for(KeybindingField::Undo), Some("Ctrl+Z")); assert_eq!( draft.value_for(KeybindingField::ClearCanvas), - Some("E, Ctrl+Shift+X") + Some("E, Ctrl+Shift+Q") ); } diff --git a/configurator/src/models/keybindings/manager.rs b/configurator/src/models/keybindings/manager.rs index d8667361..c09a13b2 100644 --- a/configurator/src/models/keybindings/manager.rs +++ b/configurator/src/models/keybindings/manager.rs @@ -487,8 +487,8 @@ mod tests { #[test] fn conflict_filter_includes_every_claimant() { let (mut draft, defaults) = drafts(); - draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); - draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); let summary = ShortcutManagerSummary::from_drafts(&draft, &defaults); let visible = summary.visible_fields( ShortcutManagerFilter::Conflicts, @@ -651,10 +651,10 @@ mod tests { #[test] fn next_review_conflict_names_the_other_claimant() { let (mut draft, _defaults) = drafts(); - draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+X".to_string()); - draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+X".to_string()); + draft.set(KeybindingField::ClearCanvas, "Ctrl+Shift+Q".to_string()); + draft.set(KeybindingField::ToggleToolbar, "Ctrl+Shift+Q".to_string()); let (field, binding, claimants) = next_review_conflict(&draft).expect("conflict"); - assert_eq!(binding.to_string(), "Ctrl+Shift+X"); + assert_eq!(binding.to_string(), "Ctrl+Shift+Q"); assert!(claimants.iter().any(|claim| claim.field == Some(field) || matches!( claim.field, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ac1d6ff3..431c1da0 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1554,8 +1554,9 @@ the result to the clipboard. It reads the underlying screen capture only — never your annotations, the toolbars, or any other wayscriber chrome — and it does not change the active tool, the drawing history, or the board. -- The action is `copy_text_from_screen`. It has **no default shortcut**, because - `O` is already the orange quick color; bind one in the configurator or in +- The action is `copy_text_from_screen`, bound to Ctrl+Shift+X + ("extract text"). `O` belongs to the orange quick color, so it takes a letter + the rest of the capture family had left; rebind it in the configurator or in `[keybindings.capture]`. - It is also in the command palette (search for "OCR"), and as an optional top toolbar button (`top.utility.ocr`), hidden by default like Screenshot. @@ -1566,6 +1567,20 @@ does not change the active tool, the drawing history, or the board. does nothing. - On a solid whiteboard or blackboard with no visible screen capture, OCR refuses rather than reading the board. +- Ctrl+A reads the whole displayed image, so a full screen of text + does not need a drag across the whole output. The selector says so along the + top until your first drag, the same hint strip the region picker uses and + under the same `[capture.region] show_legend` setting. +- While recognition runs, a band sweeps the region being read. When it finishes, + a short card beside the region says what happened — copied and how many + characters, no text found, or that recognition failed — and fades after a few + seconds. Any click, touch, stylus press or key dismisses the card early; a + sweep still waiting on the recognizer is left alone, so a stray keystroke + cannot discard a result that is about to arrive. The card never shows the + recognized text: Wayscriber keeps screen contents out of its own UI, and the + text goes only to the clipboard. With `[ui] reduced_motion` set, the region is + marked with a static tint instead of a moving band and the card appears as + soon as the result does. - An active OCR selection cancels if the displayed screen image changes, the zoom level or pan changes, or freeze, output, scale, or display layout state is replaced. @@ -2067,8 +2082,8 @@ export_all_boards_pdf_file = [] open_capture_folder = ["Ctrl+Alt+O"] # Select a screen region and copy the text recognized in it (needs Tesseract). -# Unbound by default: "O" is already the orange quick color. -copy_text_from_screen = [] +# Ctrl+A inside the selector reads the whole screen instead. +copy_text_from_screen = ["Ctrl+Shift+X"] # Toggle frozen mode toggle_frozen_mode = ["Ctrl+Shift+F"] diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 1ffff8f3..a9fa8944 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -119,10 +119,15 @@ pub(super) fn run_event_loop( // settled fade contributes nothing. let animation_timeout = min_timeout( min_timeout( - state.ui_animation_timeout(now), - state.top_strip_fade_timeout(now), + min_timeout( + state.ui_animation_timeout(now), + state.top_strip_fade_timeout(now), + ), + state.inline_toolbar_tooltip_timeout(now), ), - state.inline_toolbar_tooltip_timeout(now), + // A still OCR card under reduced motion asks for no frames, so it + // needs one deadline to be taken away on. + state.input_state.ocr_scan_wake_after(now), ); let toolbar_handoff_timeout = state.toolbar_drag_handoff_timeout(now); let autosave_timeout = session_save::autosave_timeout(state, now); @@ -260,6 +265,9 @@ pub(super) fn run_event_loop( if !capture_active && state.ui_animation_due(std::time::Instant::now()) { state.input_state.needs_redraw = true; } + if state.input_state.ocr_scan_due(std::time::Instant::now()) { + state.input_state.needs_redraw = true; + } // When the radial paint deadline passes, this requests the redraw // that paints the menu (no-op before the deadline and after paint). diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index ece5f3e3..4fdcfffb 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -160,6 +160,9 @@ impl KeyboardHandler for WaylandState { // Any fresh key press ends the previous auto-repeat; a repeatable one // re-arms it at the end of this handler. self.clear_key_repeat(); + // A finished scan card is transient chrome: the next interaction of any + // kind takes it away rather than making the user wait it out. + self.input_state.dismiss_ocr_scan_result(); if self.input_state.region_is_engaged() { if matches!(key, Key::Space) && self.toggle_region_window_snap() { self.update_pointer_cursor(false, conn); @@ -228,7 +231,7 @@ impl KeyboardHandler for WaylandState { } return; } - if region_capture_select_all_pressed( + if region_select_all_pressed( self.input_state.region_is_active(), self.input_state.region_state().purpose(), self.input_state.modifiers.ctrl, @@ -567,14 +570,20 @@ fn screen_modal_swallows_key_release( region_selector_engaged || eyedropper_engaged } -fn region_capture_select_all_pressed( +/// `Ctrl+A` takes the whole displayed image. Recognition wants it as much as +/// capture does — reading a full screen of text should not need a drag across +/// the whole output — so Measure is the only purpose left out, having nothing +/// to submit. +fn region_select_all_pressed( region_active: bool, purpose: Option, ctrl: bool, key: Key, ) -> bool { + use crate::input::state::RegionPurposeTag; + region_active - && purpose.is_some_and(crate::input::state::RegionPurposeTag::is_capture) + && purpose.is_some_and(|purpose| purpose.is_capture() || purpose == RegionPurposeTag::Ocr) && ctrl && matches!(key, Key::Char('a' | 'A')) } @@ -666,39 +675,48 @@ mod tests { } #[test] - fn ctrl_a_selects_all_only_for_an_active_capture_picker() { + fn ctrl_a_selects_all_for_capture_and_recognition_but_not_measure() { use crate::input::state::RegionPurposeTag; - assert!(region_capture_select_all_pressed( - true, - Some(RegionPurposeTag::CaptureDeliver), - true, - Key::Char('a'), - )); - assert!(region_capture_select_all_pressed( - true, - Some(RegionPurposeTag::CaptureInteractive), - true, - Key::Char('A'), - )); - assert!(!region_capture_select_all_pressed( - false, - Some(RegionPurposeTag::CaptureDeliver), - true, - Key::Char('a'), - )); - assert!(!region_capture_select_all_pressed( - true, - Some(RegionPurposeTag::Ocr), - true, - Key::Char('a'), - )); - assert!(!region_capture_select_all_pressed( - true, - Some(RegionPurposeTag::CaptureDeliver), - false, - Key::Char('a'), - )); + for purpose in [ + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + RegionPurposeTag::Ocr, + ] { + assert!( + region_select_all_pressed(true, Some(purpose), true, Key::Char('a')), + "{purpose:?} can submit the whole image" + ); + assert!(region_select_all_pressed( + true, + Some(purpose), + true, + Key::Char('A') + )); + } + + assert!( + !region_select_all_pressed(true, Some(RegionPurposeTag::Measure), true, Key::Char('a')), + "measure has nothing to submit" + ); + assert!( + !region_select_all_pressed( + false, + Some(RegionPurposeTag::CaptureDeliver), + true, + Key::Char('a') + ), + "no selector open" + ); + assert!( + !region_select_all_pressed( + true, + Some(RegionPurposeTag::CaptureDeliver), + false, + Key::Char('a') + ), + "plain A is a colour, not select-all" + ); } #[test] diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 2783d209..dd87f20e 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -34,6 +34,10 @@ impl WaylandState { .note_input_hud_mouse(&input_hud_button_label(button), self.input_state.modifiers); } + // A finished scan card is transient chrome: the next interaction of any + // kind takes it away rather than making the user wait it out. + self.input_state.dismiss_ocr_scan_result(); + let help_press_source = HelpOverlayPressSource::Pointer(button); if !self.input_state.show_help { // A new press proves any older help-owned sequence for this button diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 9caabfba..8a646fad 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -243,6 +243,10 @@ impl Dispatch for WaylandState { if state.stylus_contact_retired { return; } + // Before any surface-specific routing: a press on the Review + // bar, a toolbar or an eyedropper never reaches the frame + // commit, and the card has to go for all of them. + state.input_state.dismiss_ocr_scan_result(); if state.input_state.region_is_active() { if state.stylus_on_toolbar { state.cancel_region_for_toolbar_interaction(); diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 8174d4ff..a3105a38 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -194,6 +194,9 @@ impl WaylandState { surface: &wl_surface::WlSurface, position: (f64, f64), ) -> TouchTarget { + // A finished scan card is transient chrome: the next interaction of any + // kind takes it away rather than making the user wait it out. + self.input_state.dismiss_ocr_scan_result(); let target = self.classify_touch_surface(surface); let Some(screen_position) = self.touch_screen_position(surface, position, target) else { return TouchTarget::Other; diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 72eb1439..4364da4c 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -238,6 +238,8 @@ pub struct StateData { pub(super) prev_color_picker_damage: Option, pub(super) prev_tool_preview_damage: Option, pub(super) prev_shape_measure_badge_damage: Option, + /// Union the OCR scan overlay covered last frame, so its sweep is cleared. + pub(super) prev_ocr_scan_damage: Option, /// Previous-frame strips for Measure Mode's crosshair, frame, and readout. pub(super) prev_measure_picker_damage: Vec, /// Idle-fade engine for the top-strip islands; its value is published @@ -334,6 +336,7 @@ impl StateData { prev_color_picker_damage: None, prev_tool_preview_damage: None, prev_shape_measure_badge_damage: None, + prev_ocr_scan_damage: None, prev_measure_picker_damage: Vec::new(), top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade::new(), shortcut_coach: super::onboarding::ShortcutCoachSession::default(), diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 72606c14..ef1d9e45 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -7,6 +7,7 @@ use crate::backend::wayland::acquisition::{ScreenAcquisitionOutcome, ScreenAcquisitionOwner}; use crate::backend::wayland::zoom::ZoomWaiterOwner; +use crate::input::state::OcrScanOutcome; use crate::input::state::{ RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority, }; @@ -346,11 +347,36 @@ impl WaylandState { // The crop is taken while the capture is still held: releasing first // would leave the worker reading pixels that no longer exist. - match self.crop_ocr_selection(rect) { - Ok(pixels) => { - self.cancel_ocr(); - self.submit_ocr_request(pixels); - } + self.submit_ocr_for_rect(rect); + true + } + + /// Recognize an already-chosen rectangle — the whole image, from `Ctrl+A`. + pub(in crate::backend::wayland) fn submit_whole_image_ocr( + &mut self, + rect: crate::screen_pixels::ImagePixelRect, + ) { + self.submit_ocr_for_rect(rect); + } + + /// The one path from a chosen rectangle to a running recognition: map the + /// area the sweep will cover, crop while the source is still held, release + /// it, submit, and start the overlay. Both entry points share it so a fix + /// to one cannot leave the other behind. + fn submit_ocr_for_rect(&mut self, rect: crate::screen_pixels::ImagePixelRect) { + // Mapped before the region is released: the token is what turns the + // authoritative image rectangle into the surface pixels the sweep is + // painted over. + let scan_region = match self.data.active_screen_region { + Some(ActiveScreenRegion::Ready { source, .. }) => Some( + crate::backend::wayland::state::screen_image::screen_rect_for_image_rect( + &source, rect, + ), + ), + _ => None, + }; + let pixels = match self.crop_ocr_selection(rect) { + Ok(pixels) => pixels, Err(message) => { self.cancel_ocr(); self.input_state.push_toast( @@ -358,9 +384,20 @@ impl WaylandState { TOAST_SOURCE, Toast::warning(message), ); + return; } + }; + self.cancel_ocr(); + // The overlay starts only for a request that was actually accepted. A + // refused submission produces no completion, and an overlay waiting on + // one would sweep until something dismissed it. + let Some(id) = self.submit_ocr_request(pixels) else { + return; + }; + if let Some(region) = scan_region { + self.input_state + .begin_ocr_scan(id, region, std::time::Instant::now()); } - true } fn crop_ocr_selection( @@ -376,7 +413,12 @@ impl WaylandState { }) } - fn submit_ocr_request(&mut self, pixels: crate::screen_pixels::PackedArgb32) { + /// Hand the crop to the worker, reporting the accepted request. `None` + /// means nothing is running and no completion will arrive. + fn submit_ocr_request( + &mut self, + pixels: crate::screen_pixels::PackedArgb32, + ) -> Option { let languages = OcrLanguages::from_validated(self.config.capture.resolved_ocr_languages()); log::debug!( "OCR submitting {}x{} crop for languages {}", @@ -390,6 +432,7 @@ impl WaylandState { // the selection it publishes. self.suppress_focus_exit_for(std::time::Duration::from_millis(1500)); log::debug!("OCR request {id} started"); + Some(id) } Err(OcrSubmitError::Busy { .. }) => { self.input_state.push_toast( @@ -397,6 +440,7 @@ impl WaylandState { TOAST_SOURCE, Toast::info("OCR is already running"), ); + None } Err(error) => { log::warn!("Failed to start screen text recognition: {error}"); @@ -405,6 +449,7 @@ impl WaylandState { TOAST_SOURCE, Toast::warning("Could not start screen text recognition."), ); + None } } } @@ -423,6 +468,14 @@ impl WaylandState { }), } => { log::debug!("OCR request {id} copied {character_count} characters"); + self.input_state.settle_ocr_scan( + id, + OcrScanOutcome::Copied { + character_count, + replaced_invalid_utf8, + }, + std::time::Instant::now(), + ); let message = if replaced_invalid_utf8 { "Text copied (some characters were unreadable)" } else { @@ -435,9 +488,14 @@ impl WaylandState { ); } OcrPoll::Ready { + id, outcome: Ok(OcrSuccess::NoTextFound), - .. } => { + self.input_state.settle_ocr_scan( + id, + OcrScanOutcome::NoTextFound, + std::time::Instant::now(), + ); self.input_state.push_toast( ToastPriority::Info, TOAST_SOURCE, @@ -449,6 +507,11 @@ impl WaylandState { outcome: Err(failure), } => { log::warn!("OCR request {id} failed: {failure:?}"); + self.input_state.settle_ocr_scan( + id, + OcrScanOutcome::Failed, + std::time::Instant::now(), + ); let toast = match failure { OcrFailure::EngineMissing | OcrFailure::LanguageMissing { .. } => { Toast::warning(failure.message()) @@ -460,6 +523,11 @@ impl WaylandState { } OcrPoll::WorkerLost { id, reason } => { log::error!("OCR request {id} lost its worker: {reason}"); + self.input_state.settle_ocr_scan( + id, + OcrScanOutcome::Failed, + std::time::Instant::now(), + ); self.input_state.push_toast( ToastPriority::Critical, TOAST_SOURCE, @@ -508,6 +576,17 @@ impl WaylandState { let _ = ctx.stroke(); draw_scan_corners(ctx, x, y, w, h); } + // Whole-image recognition is a keystroke with nothing on screen to + // suggest it. The capture picker teaches its keys here; recognition + // reuses the same strip, honours the same `show_legend` setting, and + // dismisses on the first drag through the shared selector state. + if self.config.capture.region.show_legend && !self.region_picker_legend_dismissed() { + crate::ui::render_region_legend( + ctx, + (screen_width, screen_height), + crate::ui::OCR_LEGEND_TEXT, + ); + } let _ = ctx.restore(); } } diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs index 1696e937..b76a75d4 100644 --- a/src/backend/wayland/state/region_capture/delivery.rs +++ b/src/backend/wayland/state/region_capture/delivery.rs @@ -204,10 +204,14 @@ impl WaylandState { }; self.clear_region_window_snap(); self.retire_region_selection_owner(self.input_state.region_state().selection_owner()); - if purpose == RegionPurposeTag::CaptureInteractive { - self.enter_region_review(rect); - } else { - self.submit_region_capture(rect); + match purpose { + RegionPurposeTag::CaptureInteractive => { + self.enter_region_review(rect); + } + RegionPurposeTag::Ocr => self.submit_whole_image_ocr(rect), + RegionPurposeTag::CaptureDeliver | RegionPurposeTag::Measure => { + self.submit_region_capture(rect); + } } } diff --git a/src/backend/wayland/state/region_capture/geometry.rs b/src/backend/wayland/state/region_capture/geometry.rs index d7a83a52..9b7b61a6 100644 --- a/src/backend/wayland/state/region_capture/geometry.rs +++ b/src/backend/wayland/state/region_capture/geometry.rs @@ -105,11 +105,14 @@ pub(super) fn selection_geometry( }) } +/// The whole displayed image, for the purposes that can submit one. Measure +/// has nothing to submit; capture and recognition both do, and reading a full +/// screen of text should not require dragging across the whole output. pub(super) fn whole_image_rect( purpose: RegionPurposeTag, bounds: (u32, u32), ) -> Option { - if !purpose.is_capture() { + if purpose == RegionPurposeTag::Measure { return None; } ImagePixelRect::whole(bounds) diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs index 55a9c56d..3964a075 100644 --- a/src/backend/wayland/state/region_capture/tests/review.rs +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -434,7 +434,7 @@ fn ocr_owner_loss_requests_its_existing_terminal_cancel_path() { } #[test] -fn whole_image_is_available_only_to_capture_purposes() { +fn whole_image_is_available_to_every_purpose_that_can_submit_one() { let capture = capture_region(); let RegionSelectionFinalize::Selected { purpose, rect } = capture .whole_image_selection() @@ -444,7 +444,26 @@ fn whole_image_is_available_only_to_capture_purposes() { }; assert_eq!(purpose, RegionPurposeTag::CaptureDeliver); assert_eq!((rect.x(), rect.y(), rect.size()), (0, 0, (100, 80))); - assert_eq!(ocr_region(1.0).whole_image_selection(), None); + + // Recognition wants the whole image as much as capture does: reading a + // full screen of text should not need a drag across the whole output. + let RegionSelectionFinalize::Selected { purpose, rect } = ocr_region(1.0) + .whole_image_selection() + .expect("ocr whole image") + else { + panic!("whole image must be a selected result") + }; + assert_eq!(purpose, RegionPurposeTag::Ocr); + assert_eq!((rect.x(), rect.y(), rect.size()), (0, 0, (100, 80))); + + // Measure has nothing to submit. + let measure = ActiveScreenRegion::Measure { + generation: 1, + bounds: (100, 80), + anchor: None, + edge: None, + }; + assert_eq!(measure.whole_image_selection(), None); } #[test] diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index 655796d7..b4becc32 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -104,6 +104,7 @@ impl WaylandState { blocked_feedback_active, text_edit_entry_active, input_hud_animating, + ocr_scan_animating, ) = record_stage!(advance_animations, { ( self.input_state.advance_click_highlights(now), @@ -112,6 +113,7 @@ impl WaylandState { self.input_state.advance_blocked_feedback(now), self.input_state.advance_text_edit_entry_feedback(now), self.input_state.advance_input_hud(now), + self.input_state.advance_ocr_scan(now), ) }); let ui_animation_active = highlight_active @@ -119,7 +121,8 @@ impl WaylandState { || ui_toast_active || blocked_feedback_active || text_edit_entry_active - || input_hud_animating; + || input_hud_animating + || ocr_scan_animating; self.update_ui_animation_tick(now, ui_animation_active); let keep_rendering = ui_animation_active && self.ui_animation_interval.is_none(); diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 16680317..aa7c3f89 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -310,6 +310,10 @@ impl WaylandState { self.render_capture_picker(ctx, width, height); } + // The OCR scan overlay sits over the live view, above the toolbars + // it reports on but below the modal surfaces below. + self.render_ocr_scan(ctx, width, height); + // Modal overlays render last (on top of everything including toolbars) if !capture_picker { if let Some(card) = self.first_run_onboarding_card() { @@ -323,6 +327,25 @@ impl WaylandState { } } + /// The scan band while recognition runs, then the outcome card. The card + /// reports what happened, never the recognized text: keeping screen + /// contents out of application state is an invariant of `src/ocr`. + fn render_ocr_scan(&self, ctx: &cairo::Context, width: u32, height: u32) { + let Some(scan) = self.input_state.ocr_scan() else { + return; + }; + let now = std::time::Instant::now(); + if let Some((outcome, shown)) = scan.result(now) { + crate::ui::render_ocr_scan_result(ctx, scan.region(), outcome, shown, (width, height)); + } else if let Some(progress) = scan.sweep_progress(now) { + crate::ui::render_ocr_scan_sweep(ctx, scan.region(), progress); + } else if scan.is_scanning() { + // Reduced motion: the region is still marked as being read, just + // without a band travelling across it. + crate::ui::render_ocr_scan_still(ctx, scan.region()); + } + } + fn render_capture_picker(&self, ctx: &cairo::Context, width: u32, height: u32) { let purpose = self.input_state.region_state().purpose(); if !self.input_state.region_is_active() diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index f703e0cb..aaa14a8b 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -271,6 +271,25 @@ impl WaylandState { ); self.data.prev_shape_measure_badge_damage = measure_badge_rect; + // The scan overlay spans its region and, once settled, the outcome card + // beside it. Both move only when the phase changes, so the previous + // union is re-emitted to clear the sweep it leaves behind. + // Visibility, not animation: a static overlay under reduced motion + // still has to be damaged and cleared even though it asks for no + // frames of its own, so this keys off the overlay itself. + let ocr_scan_rect = self.input_state.ocr_scan().and_then(|scan| { + let outcome = scan + .result(std::time::Instant::now()) + .map(|(outcome, _)| outcome); + effect_rect( + crate::ui::ocr_scan_geometry(scan.region(), outcome, (width, height)), + width, + height, + ) + }); + push_effect_damage(&mut regions, self.data.prev_ocr_scan_damage, ocr_scan_rect); + self.data.prev_ocr_scan_damage = ocr_scan_rect; + let measure_picker_damage = if self.input_state.region_state().purpose() == Some(crate::input::state::RegionPurposeTag::Measure) { diff --git a/src/config/keybindings/config/types/bindings/capture.rs b/src/config/keybindings/config/types/bindings/capture.rs index dc8a3934..972315b5 100644 --- a/src/config/keybindings/config/types/bindings/capture.rs +++ b/src/config/keybindings/config/types/bindings/capture.rs @@ -61,8 +61,9 @@ pub struct CaptureKeybindingsConfig { #[serde(default = "default_open_capture_folder")] pub open_capture_folder: Vec, - /// Screen text recognition. Unbound by default: `O` already selects the - /// orange quick color, so a default here would silently repurpose it. + /// Screen text recognition, on `Ctrl+Shift+X` — extract text. `O` selects + /// the orange quick color and every other mnemonic is taken, so it sits + /// with the rest of the capture family on a letter nothing else wants. #[serde(default = "default_copy_text_from_screen")] pub copy_text_from_screen: Vec, } diff --git a/src/config/keybindings/defaults/capture.rs b/src/config/keybindings/defaults/capture.rs index b362a160..0e8c3e0b 100644 --- a/src/config/keybindings/defaults/capture.rs +++ b/src/config/keybindings/defaults/capture.rs @@ -74,8 +74,11 @@ pub(crate) fn default_open_capture_folder() -> Vec { vec!["Ctrl+Alt+O".to_string()] } -/// Deliberately empty: `O` is the orange quick color and no other -/// conflict-free chord is obviously right, so the user picks one. +/// e**X**tract text. `O` is the orange quick color and every other mnemonic is +/// taken — `Ctrl+Shift+O` captures the active window, `Ctrl+Alt+O` opens the +/// capture folder, `R` is spoken for in both modifier sets, and `Ctrl+Shift+T` +/// returns to transparent — so this sits with the rest of the capture family in +/// `Ctrl+Shift+` on a letter nothing else wants. pub(crate) fn default_copy_text_from_screen() -> Vec { - Vec::new() + vec!["Ctrl+Shift+X".to_string()] } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index f7b45959..7fa5da0b 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -482,19 +482,22 @@ fn screen_eyedropper_defaults_to_i_and_maps_when_reconfigured() { } #[test] -fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { +fn screen_text_recognition_has_a_chord_and_still_leaves_o_to_orange() { let mut config = KeybindingsConfig::default(); - assert!(config.capture.copy_text_from_screen.is_empty()); + assert_eq!( + config.capture.copy_text_from_screen, + vec!["Ctrl+Shift+X".to_string()] + ); let default_map = config.build_action_map().unwrap(); assert_eq!( default_map.get(&Shortcut::parse("O").unwrap()), - Some(&Action::SetColorOrange) + Some(&Action::SetColorOrange), + "the quick colour keeps the letter recognition would have wanted" ); - assert!( - !default_map - .values() - .any(|action| *action == Action::CopyTextFromScreen) + assert_eq!( + default_map.get(&Shortcut::parse("Ctrl+Shift+X").unwrap()), + Some(&Action::CopyTextFromScreen) ); config.capture.copy_text_from_screen = vec!["Ctrl+Alt+T".to_string()]; @@ -831,9 +834,8 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("export_board_pdf_file", &[]), ("export_all_boards_pdf_file", &[]), ("open_capture_folder", &["Ctrl+Alt+O"]), - // Intentionally unbound: `O` is the orange quick color, and no other - // conflict-free chord is obviously right, so the user picks one. - ("copy_text_from_screen", &[]), + // `O` stays the orange quick color; recognition takes the free letter. + ("copy_text_from_screen", &["Ctrl+Shift+X"]), ("toggle_frozen_mode", &["Ctrl+Shift+F"]), ("zoom_in", &["Ctrl+Alt++", "Ctrl+Alt+="]), ("zoom_out", &["Ctrl+Alt+-", "Ctrl+Alt+_"]), diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index a7f208ab..dab24347 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -259,6 +259,7 @@ impl InputState { show_preset_toasts: true, idle_fade: true, show_tool_preview: false, + ocr_scan: None, ui_toast: None, toast_queue: super::super::toast_queue::ToastQueue::default(), ui_toast_bounds: None, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 20f631a9..2a9ce7f6 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -502,6 +502,9 @@ pub struct InputState { pub idle_fade: bool, /// Whether to show the cursor tool preview bubble pub show_tool_preview: bool, + /// The scan-band overlay shown while screen text recognition runs, and the + /// outcome card that follows it. + pub(crate) ocr_scan: Option, /// Active (visible) UI toast (errors/warnings/info) pub(crate) ui_toast: Option, /// Pending toasts waiting behind the active one, plus rate-limit memory diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 3d4ca2e0..f42b95d4 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -24,7 +24,7 @@ mod session_preflight_exact; mod status_hud; mod tool_controls; mod tour; -mod utility; +pub(crate) mod utility; mod zoom_chip; pub use base::{ diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index 1cd4857d..e87486fd 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -7,6 +7,7 @@ mod help_overlay; mod interaction; mod launcher; mod light_mode; +pub(crate) mod ocr_scan; mod pending; mod presenter_mode; mod render_profiles; diff --git a/src/input/state/core/utility/ocr_scan.rs b/src/input/state/core/utility/ocr_scan.rs new file mode 100644 index 00000000..b2482d29 --- /dev/null +++ b/src/input/state/core/utility/ocr_scan.rs @@ -0,0 +1,627 @@ +//! The scan overlay that runs while screen text recognition works. +//! +//! Recognition happens on a worker and can take a second or more, with nothing +//! on screen to say the region was even read. A band sweeps the selected +//! rectangle while the worker runs, and a short card reports the outcome when +//! it finishes. +//! +//! The card carries the outcome, never the recognized text: `src/ocr` keeps +//! recognized text out of application state entirely, and drawing it here would +//! put screen contents into the overlay's own UI. See `src/ocr/AGENTS.md`. + +use std::time::{Duration, Instant}; + +use super::super::base::InputState; +use crate::ocr::OcrRequestId; +use crate::util::Rect; + +/// One top-to-bottom pass of the scan band. +pub(crate) const SWEEP: Duration = Duration::from_millis(1200); +/// How long the outcome card stays up once the sweep lets it through. +const RESULT_LIFETIME: Duration = Duration::from_millis(4500); +/// The card fades over the last of its lifetime. +pub(crate) const RESULT_FADE: Duration = Duration::from_millis(450); + +/// What recognition produced, in the terms the card is allowed to show. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OcrScanOutcome { + Copied { + character_count: usize, + replaced_invalid_utf8: bool, + }, + NoTextFound, + Failed, +} + +impl OcrScanOutcome { + pub(crate) fn headline(self) -> &'static str { + match self { + Self::Copied { + replaced_invalid_utf8: false, + .. + } => "Copied to clipboard", + Self::Copied { + replaced_invalid_utf8: true, + .. + } => "Copied — some characters were unreadable", + Self::NoTextFound => "No text found", + Self::Failed => "Recognition failed", + } + } + + /// The detail line. Deliberately a count, not the text itself. + pub(crate) fn detail(self) -> Option { + match self { + Self::Copied { + character_count: 1, .. + } => Some("1 character".to_string()), + Self::Copied { + character_count, .. + } => Some(format!("{character_count} characters")), + Self::NoTextFound | Self::Failed => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OcrScanPhase { + /// The worker is running, or it has finished but the band has not reached + /// the end of its current pass. + Scanning { + /// The outcome and the instant the band may hand over, measured from + /// `started`. Fixed when recognition settles rather than recomputed per + /// tick: a deadline derived from the current elapsed time moves along + /// with it and is only ever reached on an exact sweep boundary, so the + /// band sweeps forever. + settled: Option<(OcrScanOutcome, Duration)>, + }, + Showing { + outcome: OcrScanOutcome, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct OcrScan { + /// The recognition this overlay is waiting on. A completion for anything + /// else belongs to a request whose overlay is already gone. + request: OcrRequestId, + /// The scanned rectangle in logical surface pixels. + region: Rect, + started: Instant, + phase: OcrScanPhase, +} + +impl OcrScan { + pub(crate) const fn region(self) -> Rect { + self.region + } + + /// Whether recognition is still being waited on, and so the region should + /// be marked as being read. + pub(crate) const fn is_scanning(self) -> bool { + matches!(self.phase, OcrScanPhase::Scanning { .. }) + } + + /// Progress of the band through its current pass, in `0.0..1.0`. `None` + /// under `[ui] reduced_motion`: the region is marked with a static + /// indicator instead of a moving band (WCAG 2.3.3). + pub(crate) fn sweep_progress(self, now: Instant) -> Option { + self.sweep_progress_for(now, crate::ui::anim::motion_enabled()) + } + + /// Split from the accessor so both motion settings can be exercised + /// without writing the process-wide flag, which parallel tests share. + pub(crate) fn sweep_progress_for(self, now: Instant, motion: bool) -> Option { + if !motion { + return None; + } + self.is_scanning().then(|| { + let elapsed = now.saturating_duration_since(self.started).as_secs_f64(); + let period = SWEEP.as_secs_f64(); + (elapsed % period) / period + }) + } + + /// The settled outcome and how long it has been up, once the band has + /// finished its pass. + pub(crate) fn result(self, now: Instant) -> Option<(OcrScanOutcome, Duration)> { + match self.phase { + OcrScanPhase::Showing { outcome } => { + Some((outcome, now.saturating_duration_since(self.started))) + } + OcrScanPhase::Scanning { .. } => None, + } + } +} + +impl InputState { + /// Start the sweep over `region` (logical surface pixels). + pub(crate) fn begin_ocr_scan(&mut self, request: OcrRequestId, region: Rect, now: Instant) { + self.ocr_scan = Some(OcrScan { + request, + region, + started: now, + phase: OcrScanPhase::Scanning { settled: None }, + }); + self.needs_redraw = true; + } + + /// Record what recognition produced. The card does not appear yet: the band + /// finishes the pass it is on first, and always at least one full pass, so + /// a fast recognition cannot cut the sweep off mid-screen. + pub(crate) fn settle_ocr_scan( + &mut self, + request: OcrRequestId, + outcome: OcrScanOutcome, + now: Instant, + ) { + self.settle_ocr_scan_for(request, outcome, now, crate::ui::anim::motion_enabled()); + } + + pub(crate) fn settle_ocr_scan_for( + &mut self, + request: OcrRequestId, + outcome: OcrScanOutcome, + now: Instant, + motion: bool, + ) { + let Some(scan) = self.ocr_scan.as_mut() else { + return; + }; + if scan.request != request { + return; + } + let elapsed = now.saturating_duration_since(scan.started); + if let OcrScanPhase::Scanning { settled } = &mut scan.phase + && settled.is_none() + { + *settled = Some((outcome, completed_sweep(elapsed, motion))); + self.needs_redraw = true; + } + } + + /// Take away a finished outcome card. A sweep still waiting on the worker + /// is left alone: it is progress feedback for work that is still running, + /// and dropping it on a stray keystroke would also discard the result that + /// recognition is about to report. + pub(crate) fn dismiss_ocr_scan_result(&mut self) -> bool { + let Some(scan) = self.ocr_scan else { + return false; + }; + if scan.is_scanning() { + return false; + } + self.ocr_scan = None; + self.needs_redraw = true; + true + } + + pub(crate) const fn ocr_scan(&self) -> Option { + self.ocr_scan + } + + /// When the overlay next changes on its own, for a loop that is not + /// already ticking for animation. + /// + /// `None` while something is moving — the animation tick covers that — and + /// `None` during a still scan, whose next change is the worker completing, + /// which wakes the loop itself. A still card is the one case that needs a + /// deadline: nothing else will wake the loop to expire it. + pub(crate) fn ocr_scan_wake_after(&self, now: Instant) -> Option { + self.ocr_scan_wake_after_for(now, crate::ui::anim::motion_enabled()) + } + + pub(crate) fn ocr_scan_wake_after_for(&self, now: Instant, motion: bool) -> Option { + if motion { + return None; + } + let scan = self.ocr_scan?; + let (_, shown) = scan.result(now)?; + Some(RESULT_LIFETIME.saturating_sub(shown)) + } + + /// Whether a still card has outlived its deadline and needs the frame that + /// takes it away. + pub(crate) fn ocr_scan_due(&self, now: Instant) -> bool { + self.ocr_scan_wake_after(now) == Some(Duration::ZERO) + } + + /// Advance the overlay, reporting whether it needs *continuous* frames. + /// + /// Only movement answers yes. Under `[ui] reduced_motion` the overlay is + /// static, so it is rendered and damaged like any other chrome but does not + /// pin a repaint at the animation frame rate for the length of a + /// recognition; `ocr_scan_wake_after` supplies the one deadline it needs. + pub fn advance_ocr_scan(&mut self, now: Instant) -> bool { + self.advance_ocr_scan_for(now, crate::ui::anim::motion_enabled()) + } + + pub(crate) fn advance_ocr_scan_for(&mut self, now: Instant, motion: bool) -> bool { + let Some(scan) = self.ocr_scan else { + return false; + }; + let elapsed = now.saturating_duration_since(scan.started); + let animating = motion; + match scan.phase { + OcrScanPhase::Scanning { settled: None } => animating, + OcrScanPhase::Scanning { + settled: Some((outcome, deadline)), + } => { + if elapsed < deadline { + return true; + } + self.ocr_scan = Some(OcrScan { + request: scan.request, + region: scan.region, + started: now, + phase: OcrScanPhase::Showing { outcome }, + }); + self.needs_redraw = true; + animating + } + OcrScanPhase::Showing { .. } => { + if elapsed < RESULT_LIFETIME { + return animating; + } + self.ocr_scan = None; + self.needs_redraw = true; + false + } + } + } +} + +/// The end of the pass `elapsed` falls in, never earlier than one full pass. +/// +/// Zero under `[ui] reduced_motion`: with no band sweeping there is nothing to +/// let finish, so the outcome is shown as soon as it arrives. +fn completed_sweep(elapsed: Duration, motion: bool) -> Duration { + if !motion { + return Duration::ZERO; + } + let sweep = SWEEP.as_millis().max(1); + let elapsed_ms = elapsed.as_millis(); + let passes = elapsed_ms.div_ceil(sweep).max(1); + Duration::from_millis(u64::try_from(passes * sweep).unwrap_or(u64::MAX)) +} + +/// Opacity of the outcome card `shown` into its lifetime. +pub(crate) fn result_opacity(shown: Duration) -> f64 { + result_opacity_for(shown, crate::ui::anim::motion_enabled()) +} + +/// Split from the accessor so both motion settings can be exercised without +/// writing the process-wide flag, which every parallel test shares. +/// +/// A card past its lifetime is gone either way; only the gradual fade into +/// that is animation, and reduced motion skips it by cutting straight from +/// fully opaque to nothing. +pub(crate) fn result_opacity_for(shown: Duration, motion: bool) -> f64 { + let remaining = RESULT_LIFETIME.saturating_sub(shown); + if remaining.is_zero() { + return 0.0; + } + if !motion || remaining >= RESULT_FADE { + return 1.0; + } + (remaining.as_secs_f64() / RESULT_FADE.as_secs_f64()).clamp(0.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + + fn request() -> OcrRequestId { + OcrRequestId::for_test(1) + } + + fn region() -> Rect { + Rect::new(100, 80, 240, 160).expect("a scan region") + } + + #[test] + fn the_band_sweeps_while_recognition_runs() { + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + + let scan = state.ocr_scan().expect("a scan is up"); + assert_eq!(scan.region(), region()); + assert_eq!(scan.sweep_progress(start), Some(0.0)); + assert_eq!(scan.result(start), None, "nothing to report yet"); + + let half = start + SWEEP / 2; + let progress = scan.sweep_progress(half).expect("still scanning"); + assert!((progress - 0.5).abs() < 1e-6, "half way down: {progress}"); + + // The band restarts rather than stopping: the worker sets the pace. + let next = start + SWEEP + SWEEP / 4; + let progress = scan.sweep_progress(next).expect("still scanning"); + assert!((progress - 0.25).abs() < 1e-6, "second pass: {progress}"); + + assert!(state.advance_ocr_scan(half), "keeps asking for frames"); + } + + #[test] + fn a_fast_recognition_still_gets_a_whole_sweep_before_the_card() { + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + state.settle_ocr_scan( + request(), + OcrScanOutcome::Copied { + character_count: 12, + replaced_invalid_utf8: false, + }, + start, + ); + + // Recognition finished almost immediately; the card must wait. + let early = start + Duration::from_millis(80); + assert!(state.advance_ocr_scan(early)); + let scan = state.ocr_scan().expect("still scanning"); + assert_eq!(scan.result(early), None, "a card here would cut the sweep"); + assert!(scan.sweep_progress(early).is_some()); + + // One full pass later it is allowed through. + let settled = start + SWEEP; + assert!(state.advance_ocr_scan(settled)); + let scan = state.ocr_scan().expect("the card is up"); + assert_eq!(scan.sweep_progress(settled), None, "the band is done"); + let (outcome, shown) = scan.result(settled).expect("an outcome"); + assert_eq!( + outcome, + OcrScanOutcome::Copied { + character_count: 12, + replaced_invalid_utf8: false, + } + ); + assert_eq!(shown, Duration::ZERO); + } + + #[test] + fn a_slow_recognition_finishes_the_pass_it_lands_in() { + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + + // Settles a third of the way through the third pass. + let landed = start + SWEEP * 2 + SWEEP / 3; + state.settle_ocr_scan(request(), OcrScanOutcome::NoTextFound, landed); + assert!(state.advance_ocr_scan(landed)); + assert_eq!( + state.ocr_scan().and_then(|scan| scan.result(landed)), + None, + "the third pass is still running" + ); + + let completed = start + SWEEP * 3; + assert!(state.advance_ocr_scan(completed)); + assert!( + state + .ocr_scan() + .and_then(|scan| scan.result(completed)) + .is_some(), + "the card appears at the end of that pass, not the next one" + ); + } + + #[test] + fn the_band_hands_over_on_a_real_tick_not_only_on_an_exact_boundary() { + // The event loop ticks on a frame clock, so `elapsed` lands wherever it + // lands. A hand-over deadline recomputed from the current elapsed time + // moves along with it and is only ever met on an exact multiple of the + // sweep, which a real tick almost never hits — the band would sweep on + // forever. Drive it the way the loop does and require it to settle. + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + state.settle_ocr_scan( + request(), + OcrScanOutcome::Copied { + character_count: 7, + replaced_invalid_utf8: false, + }, + start + Duration::from_millis(137), + ); + + let tick = Duration::from_millis(17); + let mut now = start; + let mut handed_over = None; + for _ in 0..500 { + now += tick; + state.advance_ocr_scan(now); + if let Some(scan) = state.ocr_scan() + && scan.result(now).is_some() + { + handed_over = Some(now.saturating_duration_since(start)); + break; + } + } + + let handed_over = handed_over.expect("the band must stop sweeping and show the card"); + assert!( + handed_over >= SWEEP, + "at least one full pass: {handed_over:?}" + ); + assert!( + handed_over < SWEEP * 2, + "and no more than the pass it settled in: {handed_over:?}" + ); + } + + #[test] + fn the_card_expires_on_its_own_and_stops_asking_for_frames() { + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + state.settle_ocr_scan(request(), OcrScanOutcome::NoTextFound, start); + let settled = start + SWEEP; + assert!(state.advance_ocr_scan(settled)); + + assert!(state.advance_ocr_scan(settled + RESULT_LIFETIME / 2)); + assert!(state.ocr_scan().is_some()); + + assert!(!state.advance_ocr_scan(settled + RESULT_LIFETIME)); + assert!(state.ocr_scan().is_none(), "it clears itself"); + assert!(!state.advance_ocr_scan(settled + RESULT_LIFETIME)); + } + + #[test] + fn interaction_takes_the_finished_card_but_leaves_a_running_sweep() { + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + + // Recognition is still running: the sweep is progress feedback for work + // in flight, and dropping it would also discard the result about to + // arrive. A stray keystroke must not do that. + assert!(!state.dismiss_ocr_scan_result(), "the sweep is not a card"); + assert!(state.ocr_scan().is_some(), "and it stays up"); + + state.settle_ocr_scan(request(), OcrScanOutcome::NoTextFound, start); + assert!( + !state.dismiss_ocr_scan_result(), + "still sweeping until the pass completes" + ); + + let settled = start + SWEEP; + assert!(state.advance_ocr_scan(settled)); + assert!( + state.dismiss_ocr_scan_result(), + "the card can be taken away" + ); + assert!(state.ocr_scan().is_none()); + assert!(!state.dismiss_ocr_scan_result(), "nothing left to dismiss"); + } + + #[test] + fn a_completion_for_another_request_leaves_this_sweep_alone() { + // Capacity one makes overlapping requests unlikely, but a completion + // that outlives its own overlay must not settle a newer one with a + // stale outcome. + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + state.settle_ocr_scan(OcrRequestId::for_test(99), OcrScanOutcome::Failed, start); + + assert!(state.advance_ocr_scan(start + SWEEP * 2)); + assert_eq!( + state + .ocr_scan() + .and_then(|scan| scan.result(start + SWEEP * 2)), + None, + "the sweep is still waiting on its own request" + ); + } + + #[test] + fn settling_without_a_scan_is_inert() { + // A recognition can outlive its overlay when the user dismisses first. + let mut state = make_test_input_state(); + state.settle_ocr_scan(request(), OcrScanOutcome::Failed, Instant::now()); + assert!(state.ocr_scan().is_none()); + assert!(!state.advance_ocr_scan(Instant::now())); + } + + #[test] + fn a_still_overlay_asks_for_no_frames_and_shows_its_result_at_once() { + // Reduced motion is passed in rather than written to the process-wide + // flag, which every parallel test shares. + const STILL: bool = false; + let mut state = make_test_input_state(); + let start = Instant::now(); + state.begin_ocr_scan(request(), region(), start); + + // Nothing is moving, so nothing should pin a repaint while the worker + // runs; the completion is what wakes the loop. + assert!( + !state.advance_ocr_scan_for(start, STILL), + "no frames for a still scan" + ); + assert_eq!( + state.ocr_scan_wake_after_for(start, STILL), + None, + "and no deadline either" + ); + assert_eq!( + state + .ocr_scan() + .and_then(|scan| scan.sweep_progress_for(start, STILL)), + None, + "no band travels across the region" + ); + assert!(state.ocr_scan().is_some_and(|scan| scan.is_scanning())); + + // With no sweep to finish, the outcome is shown as soon as it arrives + // rather than waiting out an animation that never ran. + state.settle_ocr_scan_for(request(), OcrScanOutcome::NoTextFound, start, STILL); + assert!(!state.advance_ocr_scan_for(start, STILL)); + assert!( + state + .ocr_scan() + .and_then(|scan| scan.result(start)) + .is_some(), + "no pass to wait for" + ); + + // The one thing a still card needs is a deadline to be taken away on. + let wake = state + .ocr_scan_wake_after_for(start, STILL) + .expect("a deadline"); + assert!(wake > Duration::ZERO && wake <= RESULT_LIFETIME); + assert!( + state.ocr_scan_wake_after_for(start + RESULT_LIFETIME, STILL) == Some(Duration::ZERO), + "due at the end of its life" + ); + assert!(!state.advance_ocr_scan_for(start + RESULT_LIFETIME, STILL)); + assert!(state.ocr_scan().is_none(), "and it is taken away"); + } + + #[test] + fn the_card_fades_over_the_end_of_its_life_and_never_shows_text() { + // Both settings are passed in rather than written to the process-wide + // flag, which every parallel test shares. + assert_eq!(result_opacity_for(Duration::ZERO, true), 1.0); + assert_eq!(result_opacity_for(RESULT_LIFETIME - RESULT_FADE, true), 1.0); + let mid = result_opacity_for(RESULT_LIFETIME - RESULT_FADE / 2, true); + assert!( + (0.0..1.0).contains(&mid), + "part way through the fade: {mid}" + ); + + // Reduced motion cuts straight from opaque to gone: no gradual fade, + // but the card still stops being painted once its life is over. + assert_eq!( + result_opacity_for(RESULT_LIFETIME - RESULT_FADE / 2, false), + 1.0 + ); + for motion in [true, false] { + assert_eq!( + result_opacity_for(RESULT_LIFETIME, motion), + 0.0, + "an expired card paints nothing either way" + ); + } + + // The card's own words: an outcome and a count, never a transcript. + let copied = OcrScanOutcome::Copied { + character_count: 42, + replaced_invalid_utf8: false, + }; + assert_eq!(copied.headline(), "Copied to clipboard"); + assert_eq!(copied.detail().as_deref(), Some("42 characters")); + assert_eq!( + OcrScanOutcome::Copied { + character_count: 1, + replaced_invalid_utf8: false, + } + .detail() + .as_deref(), + Some("1 character") + ); + assert_eq!(OcrScanOutcome::NoTextFound.detail(), None); + assert_eq!(OcrScanOutcome::Failed.headline(), "Recognition failed"); + } +} diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 74c90451..d32b3c7f 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -19,6 +19,7 @@ pub(crate) use core::board_picker::{ #[cfg(test)] pub(crate) use core::build_text_input_preview; pub use core::color_picker_popup::{HEX_INPUT_MAX_CHARS, color_to_hex, parse_hex_color}; +pub(crate) use core::utility::ocr_scan::{OcrScanOutcome, result_opacity}; #[allow(unused_imports)] pub use core::{ BLOCKED_ACTION_DURATION_MS, BoardPickerCursorHint, BoardPickerLayout, diff --git a/src/ocr/controller.rs b/src/ocr/controller.rs index b9bd185e..8f629808 100644 --- a/src/ocr/controller.rs +++ b/src/ocr/controller.rs @@ -20,6 +20,15 @@ use super::{ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct OcrRequestId(u64); +impl OcrRequestId { + /// A request identity for tests that drive consumers of a completion + /// without running the controller that would mint one. + #[cfg(test)] + pub(crate) const fn for_test(value: u64) -> Self { + Self(value) + } +} + impl fmt::Display for OcrRequestId { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(formatter) diff --git a/src/ocr/mod.rs b/src/ocr/mod.rs index c3064bd5..7080e99b 100644 --- a/src/ocr/mod.rs +++ b/src/ocr/mod.rs @@ -14,7 +14,7 @@ use crate::screen_pixels::PackedArgb32; mod controller; mod tesseract; -pub(crate) use controller::{OcrController, OcrPoll, OcrSubmitError}; +pub(crate) use controller::{OcrController, OcrPoll, OcrRequestId, OcrSubmitError}; pub(crate) use tesseract::{TesseractRecognizer, WlCopyPublisher}; /// Turns encoded image bytes into text. The production implementation shells diff --git a/src/ui.rs b/src/ui.rs index 58dddfeb..e75e0876 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,6 +10,7 @@ mod eyedropper_loupe; mod help_overlay; mod input_hud; mod measure_badge; +mod ocr_scan; mod onboarding_card; mod precision_entry; mod primitives; @@ -41,6 +42,9 @@ pub use input_hud::{input_hud_geometry, render_input_hud}; pub(crate) use measure_badge::{ ShapeMeasureBadge, measure_shape_badge, shape_measure_badge_text_style, }; +pub(crate) use ocr_scan::{ + ocr_scan_geometry, render_ocr_scan_result, render_ocr_scan_still, render_ocr_scan_sweep, +}; pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card}; pub use precision_entry::render_precision_entry_popup; /// Shared measured-text trimming, also used by the standalone about dialog. @@ -50,8 +54,9 @@ pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; pub(crate) use region_action_bar::{RegionAction, RegionActionBar}; pub(crate) use region_capture_picker::{ - RegionCaptureLoupeVisual, RegionCapturePickerVisual, RegionCaptureWindowVisual, - capture_size_text, measure_picker_damage, render_region_capture_picker, + OCR_LEGEND_TEXT, RegionCaptureLoupeVisual, RegionCapturePickerVisual, + RegionCaptureWindowVisual, capture_size_text, measure_picker_damage, + render_region_capture_picker, render_region_legend, }; pub(crate) use region_resize_handles::RegionResizeHandles; pub use status::{ diff --git a/src/ui/ocr_scan.rs b/src/ui/ocr_scan.rs new file mode 100644 index 00000000..936a01d3 --- /dev/null +++ b/src/ui/ocr_scan.rs @@ -0,0 +1,401 @@ +use crate::input::state::{OcrScanOutcome, result_opacity}; +use crate::ui::theme::{self, Rgba, overlay}; +use crate::util::Rect; + +use super::primitives::draw_rounded_rect; +use crate::ui_text::{UiTextStyle, measure_text, text_layout}; + +/// Tint held over the region for the whole sweep, so the scanned area stays +/// identifiable even at the moment the band is off its top edge. +const TINT: Rgba = theme::rgba(theme::ACCENT_RGB, 0.14); +const FRAME: Rgba = theme::rgba(theme::ACCENT_RGB, 0.95); +const FRAME_WIDTH: f64 = 1.5; +/// The band is a fraction of the region's height, bounded so it neither +/// disappears on a tall region nor swamps a short one. +const BAND_FRACTION: f64 = 0.35; +const BAND_MIN: f64 = 18.0; +const BAND_MAX: f64 = 64.0; + +const CARD_RADIUS: f64 = overlay::RADIUS_LG; +const CARD_PAD: f64 = 12.0; +const CARD_GAP: f64 = 6.0; +const CARD_MARGIN: f64 = 12.0; +const CARD_OFFSET: f64 = 14.0; +const HEADLINE_SIZE: f64 = 12.5; +const DETAIL_SIZE: f64 = 11.0; + +/// Where the outcome card sits, in logical surface pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct OcrScanCard { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +fn headline_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: HEADLINE_SIZE, + } +} + +fn detail_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: DETAIL_SIZE, + } +} + +/// Card text block size, measured without a drawing context so damage +/// geometry and the drawn card cannot disagree. Both go through the shared +/// measurement cache. +fn card_text_size(outcome: OcrScanOutcome) -> Option<(f64, f64)> { + let headline = measure_text(headline_style(), outcome.headline(), None)?; + let Some(detail) = outcome.detail() else { + return Some((headline.width(), headline.height())); + }; + let detail = measure_text(detail_style(), &detail, None)?; + Some(( + headline.width().max(detail.width()), + headline.height() + CARD_GAP + detail.height(), + )) +} + +/// Place the outcome card just under the scanned region, flipping above it when +/// there is no room and clamping onto the surface either way. +pub(crate) fn ocr_scan_card( + region: Rect, + outcome: OcrScanOutcome, + screen: (u32, u32), +) -> Option { + let (text_width, text_height) = card_text_size(outcome)?; + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = (text_width + CARD_PAD * 2.0).min((screen_width - CARD_MARGIN * 2.0).max(1.0)); + let height = (text_height + CARD_PAD * 2.0).min((screen_height - CARD_MARGIN * 2.0).max(1.0)); + + let bottom = f64::from(region.y.saturating_add(region.height)); + let below = bottom + CARD_OFFSET; + let preferred_y = if below + height + CARD_MARGIN <= screen_height { + below + } else { + f64::from(region.y) - CARD_OFFSET - height + }; + Some(OcrScanCard { + x: f64::from(region.x).clamp( + CARD_MARGIN, + (screen_width - width - CARD_MARGIN).max(CARD_MARGIN), + ), + y: preferred_y.clamp( + CARD_MARGIN, + (screen_height - height - CARD_MARGIN).max(CARD_MARGIN), + ), + width, + height, + }) +} + +/// Union of everything the overlay paints, for targeted damage. +pub(crate) fn ocr_scan_geometry( + region: Rect, + outcome: Option, + screen: (u32, u32), +) -> (f64, f64, f64, f64) { + let region_box = ( + f64::from(region.x) - FRAME_WIDTH, + f64::from(region.y) - FRAME_WIDTH, + f64::from(region.width) + FRAME_WIDTH * 2.0, + f64::from(region.height) + FRAME_WIDTH * 2.0, + ); + let Some(card) = outcome.and_then(|outcome| ocr_scan_card(region, outcome, screen)) else { + return region_box; + }; + let left = region_box.0.min(card.x); + let top = region_box.1.min(card.y); + let right = (region_box.0 + region_box.2).max(card.x + card.width); + let bottom = (region_box.1 + region_box.3).max(card.y + card.height); + (left, top, right - left, bottom - top) +} + +/// The region marked as being read, with no moving parts. Used under +/// `[ui] reduced_motion`, where a band sweeping the screen is exactly the kind +/// of motion the setting exists to suppress (WCAG 2.3.3). +pub(crate) fn render_ocr_scan_still(ctx: &cairo::Context, region: Rect) { + if region.width <= 0 || region.height <= 0 { + return; + } + let _ = ctx.save(); + theme::set_color(ctx, TINT); + ctx.rectangle( + f64::from(region.x), + f64::from(region.y), + f64::from(region.width), + f64::from(region.height), + ); + let _ = ctx.fill(); + let _ = ctx.restore(); + draw_frame( + ctx, + f64::from(region.x), + f64::from(region.y), + f64::from(region.width), + f64::from(region.height), + 1.0, + ); +} + +/// The sweeping band, drawn while recognition runs. `progress` walks `0.0..1.0` +/// once per pass. +pub(crate) fn render_ocr_scan_sweep(ctx: &cairo::Context, region: Rect, progress: f64) { + if region.width <= 0 || region.height <= 0 { + return; + } + let x = f64::from(region.x); + let y = f64::from(region.y); + let width = f64::from(region.width); + let height = f64::from(region.height); + + let _ = ctx.save(); + theme::set_color(ctx, TINT); + ctx.rectangle(x, y, width, height); + let _ = ctx.fill(); + + // The band enters from above the region and leaves below it, so the sweep + // covers the top and bottom edges instead of appearing to start inset. + let band = (height * BAND_FRACTION).clamp(BAND_MIN, BAND_MAX); + let band_y = y - band + progress.clamp(0.0, 1.0) * (height + band); + ctx.rectangle(x, y, width, height); + ctx.clip(); + let gradient = build_band(band_y, band); + let _ = ctx.set_source(&gradient); + ctx.rectangle(x, band_y, width, band); + let _ = ctx.fill(); + let _ = ctx.restore(); + + draw_frame(ctx, x, y, width, height, 1.0); +} + +fn build_band(top: f64, height: f64) -> cairo::LinearGradient { + let gradient = cairo::LinearGradient::new(0.0, top, 0.0, top + height); + let (r, g, b) = theme::ACCENT_RGB; + gradient.add_color_stop_rgba(0.0, r, g, b, 0.0); + gradient.add_color_stop_rgba(0.8, r, g, b, 0.5); + gradient.add_color_stop_rgba(1.0, 1.0, 1.0, 1.0, 0.9); + gradient +} + +fn draw_frame(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f64, alpha: f64) { + theme::set_color(ctx, (FRAME.0, FRAME.1, FRAME.2, FRAME.3 * alpha)); + ctx.set_line_width(FRAME_WIDTH); + ctx.rectangle(x, y, width, height); + let _ = ctx.stroke(); +} + +/// The outcome card. `shown` is how long it has been up, which drives its fade. +pub(crate) fn render_ocr_scan_result( + ctx: &cairo::Context, + region: Rect, + outcome: OcrScanOutcome, + shown: std::time::Duration, + screen: (u32, u32), +) { + let opacity = result_opacity(shown); + if opacity <= 0.0 { + return; + } + let Some(card) = ocr_scan_card(region, outcome, screen) else { + return; + }; + let _ = ctx.save(); + ctx.push_group(); + + draw_frame( + ctx, + f64::from(region.x), + f64::from(region.y), + f64::from(region.width), + f64::from(region.height), + 1.0, + ); + + theme::set_color(ctx, crate::ui::theme::popup::bg_context_menu()); + draw_rounded_rect(ctx, card.x, card.y, card.width, card.height, CARD_RADIUS); + let _ = ctx.fill(); + theme::set_color(ctx, crate::ui::theme::popup::border_context_menu()); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + card.x + 0.5, + card.y + 0.5, + card.width - 1.0, + card.height - 1.0, + CARD_RADIUS - 0.5, + ); + let _ = ctx.stroke(); + + let _ = ctx.save(); + ctx.rectangle(card.x, card.y, card.width, card.height); + ctx.clip(); + let headline = text_layout(ctx, headline_style(), outcome.headline(), None); + let headline_extents = headline.ink_extents(); + theme::set_color(ctx, overlay::TEXT_PRIMARY); + headline.show_at_baseline( + ctx, + card.x + CARD_PAD - headline_extents.x_bearing(), + card.y + CARD_PAD - headline_extents.y_bearing(), + ); + if let Some(detail) = outcome.detail() { + let layout = text_layout(ctx, detail_style(), &detail, None); + let extents = layout.ink_extents(); + theme::set_color(ctx, overlay::TEXT_TERTIARY); + layout.show_at_baseline( + ctx, + card.x + CARD_PAD - extents.x_bearing(), + card.y + CARD_PAD + headline_extents.height() + CARD_GAP - extents.y_bearing(), + ); + } + let _ = ctx.restore(); + + let _ = ctx.pop_group_to_source(); + let _ = ctx.paint_with_alpha(opacity); + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn region() -> Rect { + Rect::new(60, 50, 200, 140).expect("a scan region") + } + + fn copied() -> OcrScanOutcome { + OcrScanOutcome::Copied { + character_count: 128, + replaced_invalid_utf8: false, + } + } + + fn alpha_at(surface: &mut cairo::ImageSurface, x: usize, y: usize) -> u8 { + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("pixels"); + data[y * stride + x * 4 + 3] + } + + #[test] + fn the_band_covers_the_regions_edges_at_the_ends_of_its_pass() { + // The band enters from above and leaves below, so a sweep touches the + // first and last rows rather than starting inset. + let render = |progress: f64| { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 260).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_ocr_scan_sweep(&ctx, region(), progress); + drop(ctx); + surface + }; + + let mut start = render(0.0); + assert!(alpha_at(&mut start, 160, 51) > 0, "the top row is lit"); + let mut end = render(1.0); + assert!(alpha_at(&mut end, 160, 188) > 0, "the bottom row is lit"); + + // The tint holds over the whole region throughout, so the scanned area + // stays identifiable between passes. + let mut mid = render(0.5); + assert!(alpha_at(&mut mid, 160, 60) > 0); + assert_eq!(alpha_at(&mut mid, 10, 10), 0, "nothing outside the region"); + } + + #[test] + fn the_still_indicator_marks_the_region_without_a_band() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 260).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_ocr_scan_still(&ctx, region()); + drop(ctx); + let mut surface = surface; + + // The tint and frame mark the area, evenly: no row is brighter than + // another, because nothing is travelling across it. + assert!(alpha_at(&mut surface, 160, 60) > 0, "the region is tinted"); + assert_eq!( + alpha_at(&mut surface, 160, 60), + alpha_at(&mut surface, 160, 180), + "no band, so every row inside reads the same" + ); + assert_eq!(alpha_at(&mut surface, 10, 10), 0, "nothing outside"); + } + + #[test] + fn the_card_sits_under_the_region_and_flips_above_it_when_it_must() { + let below = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + assert!( + below.y >= f64::from(region().y + region().height), + "the usual place is under the scanned area" + ); + + let low = Rect::new(60, 300, 200, 90).expect("a low region"); + let flipped = ocr_scan_card(low, copied(), (320, 400)).expect("a card"); + assert!( + flipped.y + flipped.height <= f64::from(low.y), + "no room below, so it goes above" + ); + } + + #[test] + fn a_card_with_nowhere_to_go_is_still_placed_on_the_surface() { + let full = Rect::new(0, 0, 200, 200).expect("a full-surface region"); + let card = ocr_scan_card(full, copied(), (200, 200)).expect("a card"); + assert!(card.x >= 0.0 && card.y >= 0.0); + assert!(card.x + card.width <= 200.0); + assert!(card.y + card.height <= 200.0); + } + + #[test] + fn damage_covers_the_region_alone_while_scanning_and_the_card_once_settled() { + let scanning = ocr_scan_geometry(region(), None, (320, 400)); + assert!(scanning.0 <= f64::from(region().x)); + assert!(scanning.1 <= f64::from(region().y)); + assert!(scanning.0 + scanning.2 >= f64::from(region().x + region().width)); + + let settled = ocr_scan_geometry(region(), Some(copied()), (320, 400)); + let card = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + assert!( + settled.1 + settled.3 >= card.y + card.height, + "the union has to reach the card or its pixels are never cleared" + ); + assert!(settled.3 > scanning.3, "settling grows the damaged area"); + } + + #[test] + fn the_result_card_paints_its_outcome_and_fades_to_nothing() { + let render = |shown: Duration| { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 400).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_ocr_scan_result(&ctx, region(), copied(), shown, (320, 400)); + drop(ctx); + surface + }; + let card = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + let probe = ( + (card.x + card.width / 2.0) as usize, + (card.y + card.height / 2.0) as usize, + ); + + let mut fresh = render(Duration::ZERO); + assert!(alpha_at(&mut fresh, probe.0, probe.1) > 0, "the card is up"); + + let mut expired = render(Duration::from_secs(60)); + assert_eq!( + alpha_at(&mut expired, probe.0, probe.1), + 0, + "an expired card paints nothing at all, whatever the motion setting" + ); + } +} diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs index 15c1a22e..9c57a3f6 100644 --- a/src/ui/region_capture_picker.rs +++ b/src/ui/region_capture_picker.rs @@ -25,6 +25,10 @@ const LEGEND_FONT_SIZE: f64 = 12.0; const AREA_LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Esc: cancel"; const AREA_WITH_WINDOWS_LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel"; +/// Recognition offers no square modifier, and `Ctrl+A` reads everything rather +/// than selecting everything, so it says what it does rather than borrowing +/// the capture picker's wording. +pub(crate) const OCR_LEGEND_TEXT: &str = "Drag to read text Ctrl+A: whole screen Esc: cancel"; const WINDOW_LEGEND_TEXT: &str = "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel"; @@ -282,7 +286,7 @@ pub(crate) fn render_region_capture_picker( ); } if visual.show_legend && (visual.window.active || visual.selection.is_none()) { - draw_legend( + render_region_legend( ctx, (screen_width, screen_height), picker_legend_text(visual.window), @@ -526,7 +530,9 @@ fn draw_readout_panel( let _ = ctx.restore(); } -fn draw_legend(ctx: &cairo::Context, screen: (u32, u32), text: &str) { +/// The hint strip along the top of a region selector. Shared so every selector +/// teaches its keys the same way and in the same place. +pub(crate) fn render_region_legend(ctx: &cairo::Context, screen: (u32, u32), text: &str) { let extents = text_extents_for( ctx, "Sans", @@ -602,6 +608,39 @@ mod tests { ); } + #[test] + fn every_selector_legend_names_the_keys_that_selector_actually_has() { + // Recognition has no square modifier, and its select-all reads rather + // than selects, so it must not borrow the capture wording. + assert!(OCR_LEGEND_TEXT.contains("Ctrl+A")); + assert!( + !OCR_LEGEND_TEXT.contains("Shift"), + "recognition offers no square modifier: {OCR_LEGEND_TEXT}" + ); + for legend in [ + AREA_LEGEND_TEXT, + AREA_WITH_WINDOWS_LEGEND_TEXT, + OCR_LEGEND_TEXT, + ] { + assert!(legend.contains("Esc"), "every selector says how to leave"); + } + } + + #[test] + fn the_shared_legend_paints_across_the_top_of_any_selector() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 400).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_legend(&ctx, (800, 400), OCR_LEGEND_TEXT); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + + assert!(alpha(400, 24) > 0, "the strip sits along the top edge"); + assert_eq!(alpha(400, 300), 0, "and nowhere else"); + } + #[test] fn pointer_panel_prefers_below_right_then_flips_and_clamps() { assert_eq!(