diff --git a/README.md b/README.md index c574e5b4..d9bd6f23 100644 --- a/README.md +++ b/README.md @@ -791,6 +791,14 @@ the raw crop on the board. The native region picker shows those committed drawings over its frozen desktop while you select, so the visible preview matches the annotated export; toggling drawings off in Review immediately returns the preview to raw desktop pixels. +In Review you can also remove rows or columns from the captured pixels. Press +**Cut** (or X) and drag across the selection; later cuts use the +already-collapsed output. **Undo** (Ctrl+Z), **Redo** +(Ctrl+Y or Ctrl+Shift+Z), and **Reset** apply only to +this Review — they never change board drawings. After any cut the source crop +is locked until you undo all cuts or Reset, and the loupe is hidden while Cut +is armed or cuts exist. Copy, Save, Both, and Board all receive the collapsed +result; Board keeps the original top-left and shrinks to the output size. On a transparent board, full-screen and legacy `slurp` captures retain the desktop behind the annotations; a solid board retains its canvas background. The native region picker always composites committed drawings over its frozen diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 0d4ec2f1..30cf48db 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -124,6 +124,7 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → |-------------|---------| | `mod.rs` | Public exports and shared submodules. | | `manager.rs` | `CaptureManager` – unique owner of capacity-one request/completion channels, checked request IDs, status, and its Tokio worker task. | +| `band_cut.rs` | Sequential row/column band cuts on tightly packed ARGB32 rasters. Interactive region review uses this engine; it does not know about Wayland, Cairo, or capture destinations. | | `dependencies.rs` | Trait definitions (`CaptureSource`, `CaptureFileSaver`, `CaptureClipboard`) and default implementations. | | `pipeline.rs` | `perform_capture`, `deliver_image`, `deliver_document`, and capture/delivery request definitions. | | `sources/` | Strategies for acquiring image bytes: Hyprland fast-path (`hyprland.rs`), portal fallback (`portal.rs`), and URI reader/cleanup (`reader.rs`). | @@ -138,7 +139,13 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → Region actions first reserve an immutable intent and select against the frozen desktop image. Straight-delivery actions submit immediately after selection; `capture_region_interactive` enters review so Copy, Save, Both, - or Board can choose the terminal request. + or Board can choose the terminal request. Interactive review can apply + sequential band cuts through `capture::band_cut` after flattening a + snapshot. Live cut previews run on `WaylandState.region_cut_preview`, a + capacity-one `RuntimeOperationController` independent of `CaptureManager`, + so a replaceable preview cannot occupy the capture reservation slot. + Flatten, cut, and PNG encode stay off the event loop; terminal Copy, Save, + Both, or Board still submit through `CaptureManager`. 5. Canvas export snapshots persisted board content in the current panned viewport, renders PNG bytes, and calls `CaptureManager::request_image_delivery`. 6. Board PDF export snapshots active-board or all-board pages with per-page layout metadata, renders PDF bytes, and calls `CaptureManager::request_document_delivery`. 7. A mutable `CaptureManager` submission returns a checked `CaptureRequestId`. `CaptureState` records that ID and remains the sole event-side completion owner until the matching terminal result is consumed. diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index d1f38750..caa0de99 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -108,6 +108,7 @@ pub(super) fn handle_pending_actions( state.poll_text_copy_completion(); state.poll_text_paste_completion(); state.poll_region_window_query_completion(); + state.poll_region_cut_preview_completion(); state.poll_ocr_completion(); state.poll_session_file_dialog_completion(qh); state.poll_desktop_open_completion(); diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index fc5ce358..d57cefbe 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -415,6 +415,9 @@ impl WaylandState { return true; } if matches!(key, Key::Escape) { + if self.handle_region_cut_escape() { + return true; + } self.cancel_active_region_selector(); } true @@ -625,6 +628,18 @@ fn region_review_key_action(key: Key, ctrl: bool, shift: bool) -> Option Some(RegionReviewKeyAction::Submit( crate::ui::RegionAction::ToggleIncludeDrawings, )), + Key::Char('x' | 'X') if !ctrl => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::CutBand, + )), + Key::Char('z' | 'Z') if ctrl && shift => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::RedoCut, + )), + Key::Char('z' | 'Z') if ctrl => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::UndoCut, + )), + Key::Char('y' | 'Y') if ctrl && !shift => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::RedoCut, + )), _ => None, } } @@ -764,6 +779,31 @@ mod tests { )) ); assert_eq!(region_review_key_action(Key::Char('d'), true, false), None); + assert_eq!( + region_review_key_action(Key::Char('x'), false, false), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::CutBand + )) + ); + assert_eq!(region_review_key_action(Key::Char('x'), true, false), None); + assert_eq!( + region_review_key_action(Key::Char('z'), true, false), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::UndoCut + )) + ); + assert_eq!( + region_review_key_action(Key::Char('z'), true, true), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::RedoCut + )) + ); + assert_eq!( + region_review_key_action(Key::Char('y'), true, false), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::RedoCut + )) + ); } #[test] diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 74638cda..d6fb7fd8 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -24,6 +24,10 @@ struct ScreenModalCursorContext { over_selection: bool, /// The grip being dragged, or hovered when nothing is being dragged. resize_handle: Option, + /// Cut mode is armed. The pointer aims a band, not a crop move. + cut_armed: bool, + /// Cuts exist, so the source crop cannot move or resize. + crop_locked: bool, } /// Cursor for the screen-modal surfaces. Targeting keeps the crosshair; a @@ -42,6 +46,9 @@ fn screen_modal_cursor(context: ScreenModalCursorContext) -> CursorIcon { if let Some(handle) = context.resize_handle.filter(|_| context.review_dragging) { return resize_cursor(handle); } + if context.cut_armed && context.review_dragging { + return CursorIcon::Crosshair; + } if context.review_dragging { return CursorIcon::Grabbing; } @@ -54,6 +61,12 @@ fn screen_modal_cursor(context: ScreenModalCursorContext) -> CursorIcon { if let Some(handle) = context.resize_handle { return resize_cursor(handle); } + if context.cut_armed && context.over_selection { + return CursorIcon::Crosshair; + } + if context.crop_locked { + return CursorIcon::Default; + } if context.over_selection { return CursorIcon::Grab; } @@ -146,6 +159,8 @@ impl WaylandState { over_bar: self.region_review_bar_contains(point), over_selection: self.region_review_selection_contains(point), resize_handle, + cut_armed: self.region_cut_mode_armed(), + crop_locked: self.region_review_crop_locked(), } } @@ -512,6 +527,45 @@ mod tests { ); } + #[test] + fn cut_mode_uses_a_crosshair_and_locked_crops_drop_the_move_hand() { + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + cut_armed: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Crosshair + ); + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + cut_armed: true, + review_dragging: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Crosshair + ); + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + crop_locked: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Default + ); + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + cut_armed: true, + over_action: true, + over_bar: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Pointer + ); + } + #[test] fn the_action_bar_outranks_a_grip_it_is_painted_over() { // A bar clamped over the selection hides the grips underneath it, so diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 2d44765a..9bde6428 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -2,7 +2,7 @@ use log::debug; use smithay_client_toolkit::seat::pointer::{BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent}; use wayland_client::QueueHandle; -use crate::backend::wayland::state::drag_log; +use crate::backend::wayland::state::{RegionReviewPress, drag_log}; use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; use crate::input::state::HelpOverlayPressSource; @@ -11,8 +11,9 @@ use crate::ui::toolbar::ToolbarEvent; use super::*; +#[cfg(test)] fn review_action_suppresses_next_release(action: crate::ui::RegionAction) -> bool { - action != crate::ui::RegionAction::ToggleIncludeDrawings + action.is_terminal() } impl WaylandState { @@ -141,20 +142,19 @@ impl WaylandState { } match button { BTN_LEFT => { - if let Some(action) = self.region_review_action_at(event.position) { - self.submit_region_review_action(action); - // The toggle keeps Review active, so its release is consumed - // by the active-region branch. Arming the generic post-modal - // latch would swallow an unrelated canvas release later. - if review_action_suppresses_next_release(action) { - self.suppress_next_release_from(RegionInputSource::Pointer); + match self.consume_region_review_press(RegionInputSource::Pointer, event.position) { + RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => { + self.begin_region_selection( + RegionInputSource::Pointer, + event.position.0, + event.position.1, + ); + } + RegionReviewPress::Consumed { suppress_release } => { + if suppress_release { + self.suppress_next_release_from(RegionInputSource::Pointer); + } } - } else if !self.region_review_bar_contains(event.position) { - self.begin_region_selection( - RegionInputSource::Pointer, - event.position.0, - event.position.1, - ); } } BTN_RIGHT => { diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 93c9665f..95a9c145 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -8,7 +8,7 @@ use crate::{ util::Rect, }; -use crate::backend::wayland::state::WaylandState; +use crate::backend::wayland::state::{RegionReviewPress, WaylandState}; use crate::input::state::RegionInputSource; const STYLUS_CURSOR_DAMAGE_RADIUS: i32 = 64; @@ -253,13 +253,13 @@ impl WaylandState { return false; } let (x, y) = self.current_or_pending_stylus_position(); - if let Some(action) = self.region_review_action_at((x, y)) { - self.submit_region_review_action(action); - self.retire_stylus_contact(); - } else if self.region_review_bar_contains((x, y)) { - self.retire_stylus_contact(); - } else { - self.begin_region_selection(RegionInputSource::Stylus, x, y); + match self.consume_region_review_press(RegionInputSource::Stylus, (x, y)) { + RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => { + self.begin_region_selection(RegionInputSource::Stylus, x, y); + } + RegionReviewPress::Consumed { .. } => { + self.retire_stylus_contact(); + } } true } diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index a3105a38..3dad2cf8 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -6,7 +6,8 @@ use wayland_client::{ }; use crate::backend::wayland::state::{ - PerfInputSource, TouchTarget, WaylandState, debug_toolbar_drag_logging_enabled, + PerfInputSource, RegionReviewPress, TouchTarget, WaylandState, + debug_toolbar_drag_logging_enabled, }; use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; @@ -220,18 +221,20 @@ impl WaylandState { if target == TouchTarget::Toolbar || inline_hit { self.cancel_region_for_toolbar_interaction(); } else if target == TouchTarget::Overlay { - if let Some(action) = self.region_review_action_at(screen_position) { - self.submit_region_review_action(action); - return TouchTarget::Other; + match self.consume_region_review_press(RegionInputSource::Touch, screen_position) { + RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => { + self.begin_region_selection( + RegionInputSource::Touch, + screen_position.0, + screen_position.1, + ); + } + RegionReviewPress::Consumed { suppress_release } => { + if suppress_release { + self.suppress_next_release_from(RegionInputSource::Touch); + } + } } - if self.region_review_bar_contains(screen_position) { - return TouchTarget::Other; - } - self.begin_region_selection( - RegionInputSource::Touch, - screen_position.0, - screen_position.1, - ); // Unlike the one-shot eyedropper sample, an OCR region is a // drag: report the real target so motion and release still // resolve to screen coordinates and reach the selector. diff --git a/src/backend/wayland/runtime_operation.rs b/src/backend/wayland/runtime_operation.rs index 034b20bb..06051eac 100644 --- a/src/backend/wayland/runtime_operation.rs +++ b/src/backend/wayland/runtime_operation.rs @@ -16,6 +16,13 @@ impl fmt::Display for RuntimeOperationId { } } +#[cfg(test)] +impl RuntimeOperationId { + pub(in crate::backend::wayland) const fn from_test(value: u64) -> Self { + Self(value) + } +} + #[derive(Clone)] pub(in crate::backend::wayland) struct RuntimeOperationIdSource { next: Arc>>, @@ -215,6 +222,16 @@ where Ok(id) } + #[cfg(test)] + pub(in crate::backend::wayland) fn try_submit_with_spawner_for_test( + &mut self, + context: C, + operation: impl FnOnce() -> T + Send + 'static, + spawn: impl FnOnce(Box) -> std::io::Result<()>, + ) -> Result> { + self.try_submit_with_spawner(context, operation, spawn) + } + pub(in crate::backend::wayland) fn poll(&mut self) -> RuntimeOperationPoll { let Some(active) = self.active.take() else { return RuntimeOperationPoll::Idle; diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 6d0cdc24..5494b66e 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -119,6 +119,7 @@ mod region_capture; pub(in crate::backend::wayland) use region_capture::RegionCaptureIntent; #[cfg(test)] pub(in crate::backend::wayland) use region_capture::RegionPickerOptions; +pub(in crate::backend::wayland) use region_capture::RegionReviewPress; mod render; mod screen_image; mod text_clipboard; @@ -300,6 +301,12 @@ pub(super) struct WaylandState { crate::capture::window_geometry::WindowGeometryError, >, >, + /// Capacity-one Review cut preview. Independent of capture delivery so a + /// replaceable preview cannot occupy the capture reservation slot. + pub(super) region_cut_preview: RuntimeOperationController< + region_capture::CutPreviewKey, + region_capture::CutPreviewOutcome, + >, /// Text paste requests waiting behind an active read. Repeated requests in /// the current edit generation remain distinct; a new generation replaces /// stale queued requests from the old edit session. diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index ce7dfb64..0d3dc735 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -117,6 +117,8 @@ impl WaylandState { let clipboard_text_paste = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let window_query = + RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); + let region_cut_preview = RuntimeOperationController::new(runtime_operation_ids, runtime_wake.clone()); let ocr = crate::ocr::OcrController::new(runtime_wake.clone()); @@ -159,6 +161,7 @@ impl WaylandState { clipboard_text_paste, pending_text_paste: Default::default(), window_query, + region_cut_preview, ocr, gtk_toolbar: None, onboarding, diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 4364da4c..638faa79 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -190,6 +190,7 @@ pub struct StateData { pub(super) active_eyedropper_source: Option, pub(super) active_screen_region: Option, pub(super) window_snap: Option, + pub(super) region_review_edits: Option, pub(super) next_screen_region_generation: u64, pub(super) frozen_enabled: bool, pub(super) has_seen_surface_enter: bool, @@ -304,6 +305,7 @@ impl StateData { active_eyedropper_source: None, active_screen_region: None, window_snap: None, + region_review_edits: None, next_screen_region_generation: 1, frozen_enabled: false, has_seen_surface_enter: false, diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index ef1d9e45..e9fb8b26 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -17,7 +17,7 @@ use super::WaylandState; use super::acquisition::report_screen_source_activation_rejected_to; use super::region_capture::{ ActiveScreenRegion, FreezeOwnership, RegionOwnerLoss, RegionSelectionFinalize, - finalize_region_selection_event, + finalize_region_selection_with_review_edits, }; use super::screen_image::{ CropError, DisplayedScreenImage, ScreenSourceEntry, copy_image_rect, displayed_screen_image, @@ -314,9 +314,13 @@ impl WaylandState { if was_engaged && !self.input_state.region_is_engaged() { return true; } - let rect = match finalize_region_selection_event( + if self.finish_region_cut_drag(source, (x, y)) { + return true; + } + let rect = match finalize_region_selection_with_review_edits( &mut self.data.active_screen_region, &mut self.input_state, + &mut self.data.region_review_edits, source, (x, y), ) { diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index b8147239..79a12a0c 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -7,12 +7,15 @@ use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge}; mod active_state; mod board; +mod cut_preview; +mod cut_review; mod delivery; mod events; mod geometry; mod intent; mod measure; mod picker; +mod render; mod review_state; mod runtime; mod selection_state; @@ -21,9 +24,14 @@ mod window_snap; pub(super) use active_state::{ActiveScreenRegion, FreezeOwnership}; pub(in crate::backend::wayland) use board::{ - board_bounds_for_world_rect, world_rect_for_image_rect_exact, + board_bounds_for_world_rect, world_rect_for_composed_region, world_rect_for_image_rect_exact, }; +pub(in crate::backend::wayland) use cut_preview::CutPreviewOutcome; +pub(in crate::backend::wayland) use cut_review::RegionReviewPress; +pub(in crate::backend::wayland) use cut_review::{CutPreviewKey, RegionReviewEdits}; +#[cfg(test)] pub(super) use events::finalize_region_selection_event; +pub(super) use events::finalize_region_selection_with_review_edits; use events::*; pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry}; pub(in crate::backend::wayland) use intent::{RegionCaptureIntent, RegionPickerOptions}; diff --git a/src/backend/wayland/state/region_capture/board.rs b/src/backend/wayland/state/region_capture/board.rs index a818ba12..e68118c2 100644 --- a/src/backend/wayland/state/region_capture/board.rs +++ b/src/backend/wayland/state/region_capture/board.rs @@ -101,6 +101,31 @@ pub(in crate::backend::wayland) fn world_rect_for_image_rect_exact( ) } +/// Scale a source world rectangle to the composed native output size, keeping +/// the original top-left. Width and height use output/source pixel ratios. +pub(in crate::backend::wayland) fn world_rect_for_composed_region( + source_world: CanvasExportRect, + source_size: (u32, u32), + output_size: (u32, u32), +) -> Option { + if source_size.0 == 0 + || source_size.1 == 0 + || output_size.0 == 0 + || output_size.1 == 0 + || !source_world.x.is_finite() + || !source_world.y.is_finite() + || !source_world.width.is_finite() + || !source_world.height.is_finite() + || source_world.width <= 0.0 + || source_world.height <= 0.0 + { + return None; + } + let width = source_world.width * f64::from(output_size.0) / f64::from(source_size.0); + let height = source_world.height * f64::from(output_size.1) / f64::from(source_size.1); + CanvasExportRect::new(source_world.x, source_world.y, width, height) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/backend/wayland/state/region_capture/cut_preview.rs b/src/backend/wayland/state/region_capture/cut_preview.rs new file mode 100644 index 00000000..3facc9a7 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview.rs @@ -0,0 +1,1392 @@ +use std::sync::Arc; + +use crate::backend::wayland::runtime_operation::{ + RuntimeOperationPoll, RuntimeOperationSubmitError, +}; +use crate::backend::wayland::state::WaylandState; +use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; +use crate::capture::{CaptureError, CutBand}; +use crate::input::state::{Toast, ToastPriority}; +use crate::screen_pixels::{ImagePixelRect, PackedArgb32}; + +use super::super::screen_image::{ + displayed_screen_image, screen_source_is, shared_displayed_screen_image, +}; +use super::ActiveScreenRegion; +use super::cut_review::{ + CutPreviewKey, PreviewApply, RegionAnnotatedRenderContext, RegionCutBase, RegionCutPreview, + RegionRenderFingerprint, RegionReviewCorrelation, RegionReviewEdits, native_extent_display, +}; +use super::render::{RegionPixelSource, compose_shared_region_pixels, render_region_base_pixels}; + +const TOAST_SOURCE: &str = "capture"; + +pub(super) struct CutPreviewJob { + pub key: CutPreviewKey, + pub source: Option, + pub base: Option>, +} + +pub(in crate::backend::wayland) enum CutPreviewOutcome { + Success { + key: CutPreviewKey, + base: Arc, + composed: Arc, + }, + Failed { + key: CutPreviewKey, + message: String, + }, +} + +pub(super) struct RegionRenderSnapshot { + pub source: RegionPixelSource, + pub fingerprint: RegionRenderFingerprint, +} + +pub(super) fn run_cut_preview(job: CutPreviewJob) -> CutPreviewOutcome { + let key = job.key; + let base = match job.base { + Some(base) => base, + None => { + let Some(source) = job.source else { + return CutPreviewOutcome::Failed { + key, + message: "Cut preview is missing its source image.".to_string(), + }; + }; + match render_region_base_pixels(source) { + Ok(pixels) => Arc::new(pixels), + Err(error) => { + return CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }; + } + } + } + }; + match compose_shared_region_pixels(&base, &key.cuts) { + Ok(composed) => CutPreviewOutcome::Success { + key, + base, + composed, + }, + Err(error) => CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }, + } +} + +fn preview_matches_review(edits: &RegionReviewEdits, key: &CutPreviewKey) -> bool { + key.fingerprint.correlation() == &edits.correlation + && key.fingerprint.source_rect() == edits.source_rect +} + +pub(super) fn apply_cut_preview_outcome( + edits: &mut Option, + outcome: CutPreviewOutcome, + display_for: impl FnOnce( + &RegionReviewEdits, + &[CutBand], + ) -> Option, +) -> PreviewApply { + let Some(edits) = edits.as_mut() else { + return PreviewApply::Ignored; + }; + match outcome { + CutPreviewOutcome::Failed { key, .. } => { + if !preview_matches_review(edits, &key) { + return PreviewApply::Ignored; + } + if edits.mark_preview_failed(&key) { + PreviewApply::Changed + } else { + PreviewApply::Ignored + } + } + CutPreviewOutcome::Success { + key, + base, + composed, + } => { + if !preview_matches_review(edits, &key) { + return PreviewApply::Ignored; + } + let mut changed = false; + if edits + .desired_preview + .as_ref() + .is_some_and(|desired| desired.fingerprint == key.fingerprint) + { + edits.base_cache = Some(RegionCutBase { + fingerprint: key.fingerprint.clone(), + pixels: Arc::clone(&base), + }); + changed = true; + } + if edits.desired_preview.as_ref() == Some(&key) { + let Some(display) = display_for(edits, &key.cuts) else { + return if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + }; + }; + if (composed.width(), composed.height()) + != output_size_from_key(&key).unwrap_or((0, 0)) + { + return if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + }; + } + edits.ready_preview = Some(RegionCutPreview { + key, + pixels: composed, + display, + }); + edits.failed_revision = None; + changed = true; + } + if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + } + } + } +} + +fn output_size_from_key(key: &CutPreviewKey) -> Option<(u32, u32)> { + crate::capture::output_size( + ( + key.fingerprint.source_rect().width(), + key.fingerprint.source_rect().height(), + ), + &key.cuts, + ) + .ok() +} + +fn desired_preview_to_schedule( + edits: Option<&RegionReviewEdits>, + worker_active: bool, +) -> Option { + let edits = edits?; + let desired = edits.desired_preview.clone()?; + if edits.current_preview_failed() + || worker_active + || edits + .ready_preview + .as_ref() + .is_some_and(|ready| ready.key == desired) + { + return None; + } + Some(desired) +} + +fn capture_ready_correlation( + region: Option, +) -> Result { + match region { + Some(ActiveScreenRegion::Ready { + purpose, + generation, + source, + .. + }) if purpose.is_capture() => Ok(RegionReviewCorrelation { generation, source }), + _ => Err(CaptureError::ImageError( + "Region capture is not active.".to_string(), + )), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum CutPreviewSnapshotClass { + Ready, + Cancelled, + Failed { message: String }, +} + +fn classify_cut_preview_snapshot( + desired: &RegionRenderFingerprint, + live: Result<&RegionRenderFingerprint, &CaptureError>, +) -> CutPreviewSnapshotClass { + match live { + Ok(fingerprint) if fingerprint == desired => CutPreviewSnapshotClass::Ready, + Ok(fingerprint) if fingerprint.correlation().source != desired.correlation().source => { + CutPreviewSnapshotClass::Cancelled + } + Ok(_) => CutPreviewSnapshotClass::Failed { + message: "The capture source no longer matches the cut preview.".to_string(), + }, + Err(CaptureError::Cancelled(_)) => CutPreviewSnapshotClass::Cancelled, + Err(error) => CutPreviewSnapshotClass::Failed { + message: error.to_string(), + }, + } +} + +fn cut_preview_from_poll( + poll: RuntimeOperationPoll, +) -> Option { + match poll { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => None, + RuntimeOperationPoll::Ready { outcome, .. } => Some(outcome), + RuntimeOperationPoll::ProducerFailed { + context, reason, .. + } => Some(CutPreviewOutcome::Failed { + key: context, + message: reason, + }), + RuntimeOperationPoll::Disconnected { context, .. } => Some(CutPreviewOutcome::Failed { + key: context, + message: "Cut preview worker disconnected.".to_string(), + }), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CutPreviewVisibleEffect { + toast_current_failure: bool, + dirty: bool, +} + +fn visible_effect_for_cut_preview( + edits: &mut Option, + outcome: CutPreviewOutcome, + display_for: impl FnOnce( + &RegionReviewEdits, + &[CutBand], + ) -> Option, +) -> CutPreviewVisibleEffect { + let desired = edits + .as_ref() + .and_then(|edits| edits.desired_preview.clone()); + let toast_current_failure = matches!( + &outcome, + CutPreviewOutcome::Failed { key, .. } if desired.as_ref() == Some(key) + ); + if let CutPreviewOutcome::Failed { message, .. } = &outcome + && !toast_current_failure + { + log::debug!("Ignoring stale cut preview failure: {message}"); + } + let applied = apply_cut_preview_outcome(edits, outcome, display_for); + CutPreviewVisibleEffect { + toast_current_failure, + dirty: applied == PreviewApply::Changed, + } +} + +fn present_cut_preview_effect( + input: &mut crate::input::InputState, + effect: CutPreviewVisibleEffect, +) { + if effect.toast_current_failure { + input.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Could not update the cut preview."), + ); + } + if effect.dirty { + input.dirty_tracker.mark_full(); + input.needs_redraw = true; + } +} + +impl WaylandState { + fn annotated_render_context(&self) -> RegionAnnotatedRenderContext { + RegionAnnotatedRenderContext { + board_id: self.input_state.boards.active_board_id().to_string(), + page_index: self.input_state.boards.active_page_index(), + page_generation: self.input_state.boards.active_page_generation(), + canvas_content_generation: self.input_state.canvas_content_generation(), + board_view_offset: self.board_view_offset(), + text_halo_enabled: self.config.drawing.text_halo_enabled, + spotlight: crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: self.input_state.spotlight_dim_opacity, + feather: self.input_state.spotlight_feather, + }, + } + } + + fn fingerprint_for_live_render( + &self, + correlation: RegionReviewCorrelation, + rect: ImagePixelRect, + include_drawings: bool, + ) -> RegionRenderFingerprint { + if include_drawings { + RegionRenderFingerprint::Annotated { + correlation, + source_rect: rect, + context: self.annotated_render_context(), + } + } else { + RegionRenderFingerprint::Raw { + correlation, + source_rect: rect, + } + } + } + + fn retain_current_capture_image( + &self, + token: &crate::backend::wayland::state::screen_image::ScreenSourceToken, + ) -> Result, CaptureError> { + let Some(source) = displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) else { + return Err(CaptureError::Cancelled( + "The captured screen image is no longer available.".to_string(), + )); + }; + if !screen_source_is( + token, + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) { + return Err(CaptureError::Cancelled( + "The captured screen image changed.".to_string(), + )); + } + shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind).ok_or_else(|| { + CaptureError::ImageError("Could not retain the captured screen image.".to_string()) + }) + } + + fn region_pixel_source( + &self, + fingerprint: &RegionRenderFingerprint, + shared_image: std::sync::Arc, + ) -> Result { + match fingerprint { + RegionRenderFingerprint::Raw { source_rect, .. } => Ok(RegionPixelSource::Raw { + image: shared_image, + selection: *source_rect, + }), + RegionRenderFingerprint::Annotated { + source_rect, + context, + correlation, + } => { + let logical_bounds = CanvasExportRect::new( + context.board_view_offset.0, + context.board_view_offset.1, + f64::from(correlation.source.surface.0), + f64::from(correlation.source.surface.1), + ) + .ok_or_else(|| { + CaptureError::ImageError("Could not map the selected drawings.".to_string()) + })?; + Ok(RegionPixelSource::Annotated(Box::new( + CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: shared_image, + logical_bounds, + }, + selection: *source_rect, + frame: self + .input_state + .boards + .active_frame() + .clone_without_history(), + text_halo_enabled: context.text_halo_enabled, + spotlight: context.spotlight, + }, + ))) + } + } + } + + pub(super) fn snapshot_region_render( + &self, + rect: ImagePixelRect, + include_drawings: bool, + ) -> Result { + let correlation = capture_ready_correlation(self.data.active_screen_region)?; + let shared_image = self.retain_current_capture_image(&correlation.source)?; + let fingerprint = self.fingerprint_for_live_render(correlation, rect, include_drawings); + let source = self.region_pixel_source(&fingerprint, shared_image)?; + Ok(RegionRenderSnapshot { + source, + fingerprint, + }) + } + + pub(super) fn current_region_fingerprint(&self) -> Option { + let rect = self.region_review_rect()?; + let include_drawings = self.region_picker_include_drawings(); + let correlation = capture_ready_correlation(self.data.active_screen_region).ok()?; + if self + .retain_current_capture_image(&correlation.source) + .is_err() + { + return None; + } + Some(self.fingerprint_for_live_render(correlation, rect, include_drawings)) + } + + pub(super) fn schedule_region_cut_preview(&mut self) { + let Some(desired) = desired_preview_to_schedule( + self.data.region_review_edits.as_ref(), + self.region_cut_preview.is_active(), + ) else { + return; + }; + let Some(edits) = self.data.region_review_edits.as_ref() else { + return; + }; + let cached_base = edits + .base_cache + .as_ref() + .filter(|cache| cache.fingerprint == desired.fingerprint) + .map(|cache| Arc::clone(&cache.pixels)); + let source = if cached_base.is_some() { + None + } else { + match self.snapshot_region_render( + desired.fingerprint.source_rect(), + desired.fingerprint.include_drawings(), + ) { + Ok(snapshot) => match classify_cut_preview_snapshot( + &desired.fingerprint, + Ok(&snapshot.fingerprint), + ) { + CutPreviewSnapshotClass::Ready => Some(snapshot.source), + CutPreviewSnapshotClass::Cancelled => { + self.cancel_region_capture_for_source_change(); + return; + } + CutPreviewSnapshotClass::Failed { message } => { + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key: desired, + message, + }); + return; + } + }, + Err(error) => { + match classify_cut_preview_snapshot(&desired.fingerprint, Err(&error)) { + CutPreviewSnapshotClass::Cancelled => { + self.cancel_region_capture_for_source_change(); + } + CutPreviewSnapshotClass::Failed { message } => { + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key: desired, + message, + }); + } + CutPreviewSnapshotClass::Ready => { + unreachable!("a snapshot error cannot match the desired fingerprint") + } + } + return; + } + } + }; + let job = CutPreviewJob { + key: desired.clone(), + source, + base: cached_base, + }; + if let Err(failure) = self.region_cut_preview.try_submit( + desired, + "wayscriber-region-cut-preview", + move || run_cut_preview(job), + ) { + let (error, key) = failure.into_parts(); + if !matches!(error, RuntimeOperationSubmitError::Busy { .. }) { + log::debug!("Cut preview worker unavailable: {error}"); + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }); + } + } + } + + pub(in crate::backend::wayland) fn poll_region_cut_preview_completion(&mut self) { + if let Some(outcome) = cut_preview_from_poll(self.region_cut_preview.poll()) { + self.finish_cut_preview_poll(outcome); + self.schedule_region_cut_preview(); + } + } + + fn finish_cut_preview_poll(&mut self, outcome: CutPreviewOutcome) { + let effect = visible_effect_for_cut_preview( + &mut self.data.region_review_edits, + outcome, + |edits, cuts| { + native_extent_display( + &edits.correlation.source, + edits.source_rect, + crate::capture::output_size( + (edits.source_rect.width(), edits.source_rect.height()), + cuts, + ) + .ok()?, + ) + }, + ); + present_cut_preview_effect(&mut self.input_state, effect); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::state::screen_image::ScreenImageKind; + use crate::capture::CutAxis; + use crate::input::state::RegionSelection; + use wayland_client::protocol::wl_output::Transform; + + type ContextMutation = (&'static str, fn(&mut RegionAnnotatedRenderContext)); + + fn token() -> crate::backend::wayland::state::screen_image::ScreenSourceToken { + crate::backend::wayland::state::screen_image::ScreenSourceToken { + output_id: 1, + output_layout_generation: 1, + kind: ScreenImageKind::Frozen, + image_generation: 1, + image_size: (2, 1), + stride: 8, + surface: (2, 1), + output_scale: 1, + output_transform: Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } + } + + fn key(revision: u64, generation: u64) -> CutPreviewKey { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + CutPreviewKey { + fingerprint: RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation, + source: token(), + }, + source_rect: rect, + }, + revision, + cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], + } + } + + fn annotated_key(revision: u64, generation: u64) -> CutPreviewKey { + let mut key = key(revision, generation); + key.fingerprint = RegionRenderFingerprint::Annotated { + correlation: key.fingerprint.correlation().clone(), + source_rect: key.fingerprint.source_rect(), + context: RegionAnnotatedRenderContext { + board_id: "board-a".to_string(), + page_index: 2, + page_generation: 3, + canvas_content_generation: 4, + board_view_offset: (5.0, 6.0), + text_halo_enabled: true, + spotlight: crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: 0.7, + feather: 0.2, + }, + }, + }; + key + } + + fn pixels() -> Arc { + Arc::new( + PackedArgb32::new( + 2, + 1, + 8, + [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88].to_vec(), + ) + .unwrap(), + ) + } + + fn display_selection() -> RegionSelection { + RegionSelection { + start: (0.0, 0.0), + end: (1.0, 1.0), + } + } + + fn display(_: &RegionReviewEdits, _: &[CutBand]) -> Option { + Some(display_selection()) + } + + #[test] + fn matching_completion_installs_base_and_preview() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let desired = key(1, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 1; + edits.as_mut().unwrap().desired_preview = Some(desired.clone()); + let composed = Arc::new(PackedArgb32::new(1, 1, 4, vec![0x55, 0x66, 0x77, 0x88]).unwrap()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: desired, + base: pixels(), + composed: Arc::clone(&composed), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_some()); + assert_eq!( + edits + .ready_preview + .as_ref() + .map(|preview| preview.pixels.as_ref()), + Some(composed.as_ref()) + ); + assert!(edits.preview_is_current()); + } + + #[test] + fn stale_revision_may_cache_the_base_only() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let stale = key(1, 1); + let desired = key(2, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 2; + edits.as_mut().unwrap().desired_preview = Some(desired); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: stale, + base: pixels(), + composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_some()); + assert!(edits.ready_preview.is_none()); + } + + #[test] + fn different_generation_rejects_all_output() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 2, + source: token(), + }, + rect, + )); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: key(1, 1), + base: pixels(), + composed: pixels(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Ignored); + assert!(edits.unwrap().ready_preview.is_none()); + } + + #[test] + fn every_nonmatching_render_fingerprint_field_rejects_completed_output() { + let desired = annotated_key(1, 1); + let mut mismatches = Vec::new(); + + let mut different_source = desired.clone(); + let RegionRenderFingerprint::Annotated { correlation, .. } = + &mut different_source.fingerprint + else { + unreachable!("annotated fixture stays annotated"); + }; + correlation.source.image_generation = 9; + mismatches.push(("source token", different_source)); + + let mut different_rect = desired.clone(); + let RegionRenderFingerprint::Annotated { source_rect, .. } = + &mut different_rect.fingerprint + else { + unreachable!("annotated fixture stays annotated"); + }; + *source_rect = ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(); + mismatches.push(("source rectangle", different_rect)); + + let mut without_drawings = desired.clone(); + without_drawings.fingerprint = RegionRenderFingerprint::Raw { + correlation: desired.fingerprint.correlation().clone(), + source_rect: desired.fingerprint.source_rect(), + }; + mismatches.push(("include drawings", without_drawings)); + + let context_mutations: [ContextMutation; 8] = [ + ( + "board identity", + |context: &mut RegionAnnotatedRenderContext| { + context.board_id = "board-b".to_string() + }, + ), + ("page identity", |context| context.page_index += 1), + ("page generation", |context| context.page_generation += 1), + ("canvas content generation", |context| { + context.canvas_content_generation += 1; + }), + ("board view offset", |context| { + context.board_view_offset.0 += 1.0; + }), + ("text halo", |context| { + context.text_halo_enabled = !context.text_halo_enabled; + }), + ("Spotlight opacity", |context| { + context.spotlight.dim_opacity += 0.1; + }), + ("Spotlight feather", |context| { + context.spotlight.feather += 0.1; + }), + ]; + for (name, mutate) in context_mutations { + let mut mismatch = desired.clone(); + let RegionRenderFingerprint::Annotated { context, .. } = &mut mismatch.fingerprint + else { + unreachable!("annotated fixture stays annotated"); + }; + mutate(context); + mismatches.push((name, mismatch)); + } + + for (name, mismatch) in mismatches { + let mut edits = review_edits(desired.clone()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: mismatch, + base: pixels(), + composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), + }, + display, + ); + let edits = edits.unwrap(); + assert_eq!(applied, PreviewApply::Ignored, "{name}"); + assert!(edits.base_cache.is_none(), "{name} donated a stale base"); + assert!( + edits.ready_preview.is_none(), + "{name} installed a stale preview" + ); + } + } + + #[test] + fn current_failure_records_visible_state() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let desired = key(3, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 3; + edits.as_mut().unwrap().desired_preview = Some(desired.clone()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Failed { + key: desired, + message: "boom".to_string(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + assert_eq!(edits.unwrap().failed_revision, Some(3)); + } + + #[test] + fn stale_failure_does_not_touch_the_new_review() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 2, + source: token(), + }, + rect, + )); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Failed { + key: key(1, 1), + message: "old".to_string(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Ignored); + assert!(edits.unwrap().failed_revision.is_none()); + } + + fn ready(purpose: crate::input::state::RegionPurposeTag) -> ActiveScreenRegion { + ActiveScreenRegion::Ready { + purpose, + generation: 7, + source: token(), + freeze_ownership: super::super::FreezeOwnership::PreExisting, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: false, + legend_dismissed: false, + include_drawings: false, + review_resize: None, + } + } + + #[test] + fn render_snapshot_accepts_both_capture_purposes() { + use crate::input::state::RegionPurposeTag; + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).is_ok()); + assert!( + capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureInteractive))).is_ok() + ); + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).is_err()); + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Measure))).is_err()); + assert!(capture_ready_correlation(None).is_err()); + let correlation = + capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).unwrap(); + assert_eq!(correlation.generation, 7); + let err = capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).unwrap_err(); + assert!( + !err.to_string().contains("review"), + "direct delivery must not require Review: {err}" + ); + } + + #[test] + fn snapshot_classification_routes_cancel_and_current_failures() { + let desired = key(1, 1); + assert_eq!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&desired.fingerprint)), + CutPreviewSnapshotClass::Ready + ); + let other = key(1, 2); + assert!(matches!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other.fingerprint)), + CutPreviewSnapshotClass::Failed { .. } + )); + assert_eq!( + classify_cut_preview_snapshot( + &desired.fingerprint, + Err(&CaptureError::Cancelled("changed".to_string())) + ), + CutPreviewSnapshotClass::Cancelled + ); + assert!(matches!( + classify_cut_preview_snapshot( + &desired.fingerprint, + Err(&CaptureError::ImageError("nope".to_string())) + ), + CutPreviewSnapshotClass::Failed { .. } + )); + let mut other_token = token(); + other_token.image_generation = 99; + let other_source = RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation: 1, + source: other_token, + }, + source_rect: desired.fingerprint.source_rect(), + }; + assert_eq!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other_source)), + CutPreviewSnapshotClass::Cancelled + ); + let drifted = RegionRenderFingerprint::Raw { + correlation: desired.fingerprint.correlation().clone(), + source_rect: ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(), + }; + assert!(matches!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&drifted)), + CutPreviewSnapshotClass::Failed { .. } + )); + } + + #[test] + fn preview_job_composes_from_the_key_cuts() { + let desired = key(1, 1); + match run_cut_preview(CutPreviewJob { + key: desired.clone(), + source: None, + base: Some(pixels()), + }) { + CutPreviewOutcome::Success { key, composed, .. } => { + assert_eq!(key.cuts, desired.cuts); + assert_eq!((composed.width(), composed.height()), (1, 1)); + } + CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), + } + } + + #[test] + fn empty_cuts_reuse_the_cached_base_raster() { + let mut desired = key(1, 1); + desired.cuts.clear(); + let base = pixels(); + match run_cut_preview(CutPreviewJob { + key: desired, + source: None, + base: Some(Arc::clone(&base)), + }) { + CutPreviewOutcome::Success { + base: out_base, + composed, + .. + } => { + assert!(Arc::ptr_eq(&base, &out_base)); + assert!(Arc::ptr_eq(&out_base, &composed)); + assert_eq!((composed.width(), composed.height()), (2, 1)); + } + CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), + } + } + + fn review_edits(desired: CutPreviewKey) -> Option { + let rect = desired.fingerprint.source_rect(); + let mut edits = RegionReviewEdits::new(desired.fingerprint.correlation().clone(), rect); + edits.cuts = desired.cuts.clone(); + edits.revision = desired.revision; + edits.desired_preview = Some(desired); + Some(edits) + } + + fn poll_until_terminal( + controller: &mut crate::backend::wayland::runtime_operation::RuntimeOperationController< + CutPreviewKey, + CutPreviewOutcome, + >, + ) -> RuntimeOperationPoll { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match controller.poll() { + RuntimeOperationPoll::Pending { .. } => { + assert!( + std::time::Instant::now() < deadline, + "cut preview worker did not finish" + ); + std::thread::yield_now(); + } + poll => return poll, + } + } + } + + fn apply_poll( + edits: &mut Option, + input: &mut crate::input::InputState, + poll: RuntimeOperationPoll, + ) { + if let Some(outcome) = cut_preview_from_poll(poll) { + let effect = visible_effect_for_cut_preview(edits, outcome, display); + present_cut_preview_effect(input, effect); + } + } + + fn preview_controller() + -> crate::backend::wayland::runtime_operation::RuntimeOperationController< + CutPreviewKey, + CutPreviewOutcome, + > { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + crate::backend::wayland::runtime_operation::RuntimeOperationController::new( + crate::backend::wayland::runtime_operation::RuntimeOperationIdSource::new(), + wake.handle(), + ) + } + + #[test] + fn worker_error_toasts_once_and_marks_dirty_for_the_current_revision() { + let desired = key(3, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.needs_redraw = false; + let _ = input.dirty_tracker.take_region_report(2, 1); + + let mut controller = preview_controller(); + let job_key = desired.clone(); + controller + .try_submit(desired.clone(), "test-cut-preview-error", move || { + CutPreviewOutcome::Failed { + key: job_key, + message: "pixels".to_string(), + } + }) + .unwrap(); + apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); + + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(3)); + assert!(edits.as_ref().unwrap().current_preview_failed()); + assert_eq!(input.test_toast_count(), 1); + assert_eq!( + input.test_active_toast_message(), + Some("Could not update the cut preview.") + ); + assert_eq!(input.test_active_toast_key(), Some(TOAST_SOURCE)); + assert!(input.needs_redraw); + assert_eq!( + input.dirty_tracker.take_region_report(2, 1).regions.len(), + 1 + ); + + present_cut_preview_effect( + &mut input, + visible_effect_for_cut_preview( + &mut edits, + CutPreviewOutcome::Failed { + key: desired, + message: "pixels again".to_string(), + }, + display, + ), + ); + assert_eq!( + input.test_toast_count(), + 1, + "same capture key must not stack a second preview-failure toast" + ); + } + + #[test] + fn worker_panic_and_disconnect_fail_the_current_preview() { + let desired = key(4, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + controller + .try_submit(desired.clone(), "test-cut-preview-panic", || { + panic!("expected cut preview panic") + }) + .unwrap(); + apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(4)); + assert_eq!(input.test_toast_count(), 1); + + let desired = key(5, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + controller + .try_submit_with_spawner_for_test( + desired.clone(), + || panic!("must not run"), + |job| { + drop(job); + Ok(()) + }, + ) + .unwrap(); + apply_poll(&mut edits, &mut input, controller.poll()); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(5)); + assert_eq!(input.test_toast_count(), 1); + } + + #[test] + fn submit_failure_is_visible_and_busy_is_not() { + let desired = key(6, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + let failure = controller + .try_submit_with_spawner_for_test( + desired.clone(), + || panic!("must not run"), + |_job| Err(std::io::Error::other("injected spawn failure")), + ) + .unwrap_err(); + let (error, failed_key) = failure.into_parts(); + assert!(matches!( + error, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::SpawnFailed { .. } + )); + present_cut_preview_effect( + &mut input, + visible_effect_for_cut_preview( + &mut edits, + CutPreviewOutcome::Failed { + key: failed_key, + message: error.to_string(), + }, + display, + ), + ); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(6)); + assert_eq!(input.test_toast_count(), 1); + + let desired = key(7, 1); + let edits = review_edits(desired.clone()); + let input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocked_key = desired.clone(); + controller + .try_submit(desired.clone(), "test-cut-preview-busy", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Failed { + key: blocked_key, + message: "late".to_string(), + } + }) + .unwrap(); + let busy = controller + .try_submit(desired.clone(), "test-cut-preview-busy-2", || { + panic!("must not run") + }) + .unwrap_err() + .into_parts() + .0; + assert!(matches!( + busy, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } + )); + assert!(edits.as_ref().unwrap().failed_revision.is_none()); + assert_eq!(input.test_toast_count(), 0); + release_tx.send(()).unwrap(); + let _ = poll_until_terminal(&mut controller); + } + + #[test] + fn identity_mismatch_and_stale_failure_follow_current_or_silent_rules() { + let desired = key(8, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + apply_poll( + &mut edits, + &mut input, + RuntimeOperationPoll::ProducerFailed { + id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), + context: desired.clone(), + reason: "runtime operation worker reported transport identity 4, expected 3" + .to_string(), + }, + ); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(8)); + assert_eq!(input.test_toast_count(), 1); + assert!(input.needs_redraw); + + let mut next = review_edits(key(9, 2)); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.needs_redraw = false; + let _ = input.dirty_tracker.take_region_report(2, 1); + apply_poll( + &mut next, + &mut input, + RuntimeOperationPoll::ProducerFailed { + id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), + context: desired, + reason: "old picker".to_string(), + }, + ); + assert!(next.as_ref().unwrap().failed_revision.is_none()); + assert_eq!(input.test_toast_count(), 0); + assert!(!input.needs_redraw); + assert!( + input + .dirty_tracker + .take_region_report(2, 1) + .regions + .is_empty() + ); + } + + #[test] + fn busy_controller_runs_only_the_newest_desired_key_after_terminal_poll() { + let first = key(10, 1); + let second = key(11, 1); + let newest = key(12, 1); + let mut edits = review_edits(first.clone()); + let mut controller = preview_controller(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let first_job_key = first.clone(); + controller + .try_submit(first, "test-cut-preview-queued-first", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Failed { + key: first_job_key, + message: "stale".to_string(), + } + }) + .unwrap(); + + edits.as_mut().unwrap().desired_preview = Some(second); + assert!( + desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), + "busy preview work leaves the current desired key queued in Review state" + ); + edits.as_mut().unwrap().desired_preview = Some(newest.clone()); + assert!( + desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), + "a later edit replaces the queued desired key instead of appending work" + ); + + release_tx.send(()).unwrap(); + assert!(cut_preview_from_poll(poll_until_terminal(&mut controller)).is_some()); + let scheduled = desired_preview_to_schedule(edits.as_ref(), controller.is_active()) + .expect("terminal consumption schedules the newest desired key"); + assert_eq!(scheduled, newest); + + let scheduled_job_key = scheduled.clone(); + controller + .try_submit(scheduled, "test-cut-preview-queued-newest", move || { + CutPreviewOutcome::Failed { + key: scheduled_job_key, + message: "newest".to_string(), + } + }) + .unwrap(); + assert!(matches!( + poll_until_terminal(&mut controller), + RuntimeOperationPoll::Ready { + context, + outcome: CutPreviewOutcome::Failed { key, .. }, + .. + } if context == newest && key == newest + )); + } + + #[test] + fn reset_while_worker_is_active_releases_buffers_and_ignores_completion() { + let active_key = key(1, 1); + let mut edits = review_edits(active_key.clone()); + let cached_base = pixels(); + let cached_base_weak = Arc::downgrade(&cached_base); + let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); + let cached_preview_weak = Arc::downgrade(&cached_preview); + edits.as_mut().unwrap().base_cache = Some(RegionCutBase { + fingerprint: active_key.fingerprint.clone(), + pixels: cached_base, + }); + edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { + key: active_key.clone(), + pixels: cached_preview, + display: display_selection(), + }); + + let worker_base = pixels(); + let worker_base_weak = Arc::downgrade(&worker_base); + let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); + let worker_preview_weak = Arc::downgrade(&worker_preview); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let mut controller = preview_controller(); + let worker_key = active_key.clone(); + controller + .try_submit(active_key, "test-cut-preview-reset-active", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Success { + key: worker_key, + base: worker_base, + composed: worker_preview, + } + }) + .unwrap(); + + assert!(edits.as_mut().unwrap().reset()); + assert!(cached_base_weak.upgrade().is_none()); + assert!(cached_preview_weak.upgrade().is_none()); + release_tx.send(()).unwrap(); + let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); + assert_eq!( + apply_cut_preview_outcome(&mut edits, outcome, display), + PreviewApply::Ignored + ); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_none()); + assert!(edits.ready_preview.is_none()); + assert!(edits.desired_preview.is_none()); + assert!(worker_base_weak.upgrade().is_none()); + assert!(worker_preview_weak.upgrade().is_none()); + } + + #[test] + fn review_exit_and_reopen_releases_buffers_and_rejects_old_completion() { + let old_key = key(1, 1); + let mut edits = review_edits(old_key.clone()); + let cached_base = pixels(); + let cached_base_weak = Arc::downgrade(&cached_base); + let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); + let cached_preview_weak = Arc::downgrade(&cached_preview); + edits.as_mut().unwrap().base_cache = Some(RegionCutBase { + fingerprint: old_key.fingerprint.clone(), + pixels: cached_base, + }); + edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { + key: old_key.clone(), + pixels: cached_preview, + display: display_selection(), + }); + + let worker_base = pixels(); + let worker_base_weak = Arc::downgrade(&worker_base); + let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); + let worker_preview_weak = Arc::downgrade(&worker_preview); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let mut controller = preview_controller(); + let worker_key = old_key.clone(); + controller + .try_submit(old_key, "test-cut-preview-reopen", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Success { + key: worker_key, + base: worker_base, + composed: worker_preview, + } + }) + .unwrap(); + + edits = None; + assert!( + edits.is_none(), + "Review exit clears the transient edit state" + ); + assert!(cached_base_weak.upgrade().is_none()); + assert!(cached_preview_weak.upgrade().is_none()); + edits = review_edits(key(1, 2)); + release_tx.send(()).unwrap(); + let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); + assert_eq!( + apply_cut_preview_outcome(&mut edits, outcome, display), + PreviewApply::Ignored + ); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_none()); + assert!(edits.ready_preview.is_none()); + assert!(worker_base_weak.upgrade().is_none()); + assert!(worker_preview_weak.upgrade().is_none()); + } +} diff --git a/src/backend/wayland/state/region_capture/cut_review.rs b/src/backend/wayland/state/region_capture/cut_review.rs new file mode 100644 index 00000000..ff4778f6 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review.rs @@ -0,0 +1,1409 @@ +use crate::capture::{CutAxis, CutBand, output_size}; +use crate::input::InputState; +use crate::input::state::{RegionInputSource, RegionSelection}; +use crate::screen_pixels::{ImagePixelRect, ImagePoint, pixel_span}; +use crate::util::Rect; + +use super::super::screen_image::{ScreenSourceToken, screen_rect_for_native_extent}; +use super::*; +use crate::ui::{RegionActionAvailability, RegionCutStatus}; + +pub(super) const CUT_DRAG_THRESHOLD_PX: f64 = 4.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PreviewApply { + Ignored, + Changed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum CutMode { + Idle, + Armed, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland) struct CutDrag { + pub(super) owner: RegionInputSource, + pub(super) start: (f64, f64), + pub(super) current: (f64, f64), + pub(super) axis: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) struct RegionReviewCorrelation { + pub(super) generation: u64, + pub(super) source: ScreenSourceToken, +} + +/// Board and overlay facts that distinguish two annotated region renders. +/// Fingerprint and snapshot construction share one of these so halo, Spotlight, +/// and board identity cannot describe different frames. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct RegionAnnotatedRenderContext { + pub(super) board_id: String, + pub(super) page_index: usize, + pub(super) page_generation: u64, + pub(super) canvas_content_generation: u64, + pub(super) board_view_offset: (f64, f64), + pub(super) text_halo_enabled: bool, + pub(super) spotlight: crate::canvas_export::SpotlightPassSnapshot, +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) enum RegionRenderFingerprint { + Raw { + correlation: RegionReviewCorrelation, + source_rect: ImagePixelRect, + }, + Annotated { + correlation: RegionReviewCorrelation, + source_rect: ImagePixelRect, + context: RegionAnnotatedRenderContext, + }, +} + +impl RegionRenderFingerprint { + pub(super) fn correlation(&self) -> &RegionReviewCorrelation { + match self { + Self::Raw { correlation, .. } | Self::Annotated { correlation, .. } => correlation, + } + } + + pub(super) fn source_rect(&self) -> ImagePixelRect { + match self { + Self::Raw { source_rect, .. } | Self::Annotated { source_rect, .. } => *source_rect, + } + } + + pub(super) fn include_drawings(&self) -> bool { + matches!(self, Self::Annotated { .. }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) struct CutPreviewKey { + pub(super) fingerprint: RegionRenderFingerprint, + pub(super) revision: u64, + pub(super) cuts: Vec, +} + +#[derive(Debug, Clone)] +pub(in crate::backend::wayland) struct RegionCutPreview { + pub(super) key: CutPreviewKey, + pub(super) pixels: std::sync::Arc, + pub(super) display: RegionSelection, +} + +#[derive(Debug, Clone)] +pub(in crate::backend::wayland) struct RegionCutBase { + pub(super) fingerprint: RegionRenderFingerprint, + pub(super) pixels: std::sync::Arc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CutCommit { + None, + Applied, + RejectedFullAxis, +} + +#[derive(Debug)] +pub(in crate::backend::wayland) struct RegionReviewEdits { + pub(super) correlation: RegionReviewCorrelation, + pub(super) source_rect: ImagePixelRect, + pub(super) mode: CutMode, + pub(super) drag: Option, + pub(super) cuts: Vec, + pub(super) redo: Vec, + pub(super) revision: u64, + pub(super) desired_preview: Option, + pub(super) ready_preview: Option, + pub(super) base_cache: Option, + pub(super) failed_revision: Option, +} + +pub(super) fn review_edits_for_active_region( + region: Option, + rect: ImagePixelRect, +) -> Option { + let Some(ActiveScreenRegion::Ready { + purpose: crate::input::state::RegionPurposeTag::CaptureInteractive, + generation, + source, + .. + }) = region + else { + return None; + }; + Some(RegionReviewEdits::new( + RegionReviewCorrelation { generation, source }, + rect, + )) +} + +impl RegionReviewEdits { + pub(super) fn new(correlation: RegionReviewCorrelation, source_rect: ImagePixelRect) -> Self { + Self { + correlation, + source_rect, + mode: CutMode::Idle, + drag: None, + cuts: Vec::new(), + redo: Vec::new(), + revision: 0, + desired_preview: None, + ready_preview: None, + base_cache: None, + failed_revision: None, + } + } + + pub(super) fn crop_locked(&self) -> bool { + !self.cuts.is_empty() + } + + pub(super) fn loupe_suppressed(&self) -> bool { + self.mode == CutMode::Armed || self.crop_locked() + } + + pub(super) fn preview_is_current(&self) -> bool { + match &self.desired_preview { + None => self.cuts.is_empty(), + Some(desired) => self + .ready_preview + .as_ref() + .is_some_and(|ready| ready.key == *desired), + } + } + + pub(super) fn can_start_cut_drag(&self) -> bool { + self.mode == CutMode::Armed && self.preview_is_current() && self.drag.is_none() + } + + /// A current failed preview must stay failed until undo, redo, or reset + /// changes the revision. Resubmitting the same revision churns workers. + pub(super) fn current_preview_failed(&self) -> bool { + self.desired_preview.as_ref().is_some_and(|desired| { + self.failed_revision + .is_some_and(|revision| revision == desired.revision) + }) + } + + pub(super) fn status(&self) -> Option { + if self.cuts.is_empty() { + return None; + } + if self + .failed_revision + .is_some_and(|revision| revision == self.revision) + { + return Some(RegionCutStatus::Failed); + } + if !self.preview_is_current() { + return Some(RegionCutStatus::Updating); + } + None + } + + pub(super) fn availability(&self) -> RegionActionAvailability { + let preview_ok = self.preview_is_current(); + RegionActionAvailability { + terminal: preview_ok, + cut: true, + undo: !self.cuts.is_empty(), + redo: !self.redo.is_empty(), + reset: self.crop_locked() || !self.redo.is_empty(), + } + } + + /// Toggle Cut arming. Returns the abandoned drag owner, if any, so the + /// caller can retire the matching `InputState` review-move owner. + pub(super) fn toggle_mode(&mut self) -> Option { + let abandoned = if self.mode == CutMode::Armed { + self.drag.take().map(|drag| drag.owner) + } else { + None + }; + self.mode = match self.mode { + CutMode::Idle => CutMode::Armed, + CutMode::Armed => CutMode::Idle, + }; + abandoned + } + + pub(super) fn disarm_mode(&mut self) -> bool { + if self.drag.is_some() { + self.drag = None; + return true; + } + if self.mode == CutMode::Armed { + self.mode = CutMode::Idle; + return true; + } + false + } + + fn bump_revision(&mut self) -> bool { + match self.revision.checked_add(1) { + Some(next) => { + self.revision = next; + true + } + None => false, + } + } + + pub(super) fn set_desired_from(&mut self, fingerprint: RegionRenderFingerprint) { + if self.cuts.is_empty() { + self.desired_preview = None; + self.ready_preview = None; + self.base_cache = None; + self.failed_revision = None; + return; + } + self.desired_preview = Some(CutPreviewKey { + fingerprint, + revision: self.revision, + cuts: self.cuts.clone(), + }); + } + + pub(super) fn output_size(&self) -> Option<(u32, u32)> { + output_size( + (self.source_rect.width(), self.source_rect.height()), + &self.cuts, + ) + .ok() + } + + pub(super) fn displayed_output_size(&self) -> Option<(u32, u32)> { + self.ready_preview + .as_ref() + .map(|preview| (preview.pixels.width(), preview.pixels.height())) + .or_else(|| { + if self.cuts.is_empty() { + Some((self.source_rect.width(), self.source_rect.height())) + } else { + None + } + }) + } + + pub(super) fn begin_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool { + if !self.can_start_cut_drag() { + return false; + } + self.drag = Some(CutDrag { + owner, + start: point, + current: point, + axis: None, + }); + true + } + + pub(super) fn update_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool { + let Some(drag) = self.drag.as_mut() else { + return false; + }; + if drag.owner != owner { + return false; + } + drag.current = point; + if drag.axis.is_none() { + let dx = point.0 - drag.start.0; + let dy = point.1 - drag.start.1; + if dx.hypot(dy) >= CUT_DRAG_THRESHOLD_PX { + drag.axis = Some(dominant_cut_axis(dx, dy)); + } + } + true + } + + pub(super) fn finish_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + display: RegionSelection, + fingerprint: RegionRenderFingerprint, + ) -> CutCommit { + if !self.drag.as_ref().is_some_and(|drag| drag.owner == owner) { + return CutCommit::None; + } + let _ = self.update_drag(owner, point); + let Some(drag) = self.drag.take() else { + return CutCommit::None; + }; + let Some(axis) = drag.axis else { + return CutCommit::None; + }; + let Some(output) = self.output_size() else { + return CutCommit::None; + }; + let Some(band) = quantized_cut(axis, display, output, drag.start, drag.current) else { + return CutCommit::None; + }; + let mut next_cuts = self.cuts.clone(); + next_cuts.push(band); + if output_size( + (self.source_rect.width(), self.source_rect.height()), + &next_cuts, + ) + .is_err() + { + return CutCommit::RejectedFullAxis; + } + if !self.bump_revision() { + return CutCommit::None; + } + self.cuts.push(band); + self.redo.clear(); + self.failed_revision = None; + self.set_desired_from(fingerprint); + CutCommit::Applied + } + + pub(super) fn undo(&mut self, fingerprint: RegionRenderFingerprint) -> bool { + let Some(cut) = self.cuts.pop() else { + return false; + }; + if !self.bump_revision() { + self.cuts.push(cut); + return false; + } + self.redo.push(cut); + self.drag = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + true + } + + pub(super) fn redo(&mut self, fingerprint: RegionRenderFingerprint) -> bool { + let Some(cut) = self.redo.pop() else { + return false; + }; + if !self.bump_revision() { + self.redo.push(cut); + return false; + } + self.cuts.push(cut); + self.drag = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + true + } + + pub(super) fn reset(&mut self) -> bool { + if self.cuts.is_empty() && self.redo.is_empty() && self.drag.is_none() { + return false; + } + if !self.bump_revision() { + return false; + } + self.drag = None; + self.cuts.clear(); + self.redo.clear(); + self.failed_revision = None; + self.set_desired_from(RegionRenderFingerprint::Raw { + correlation: self.correlation.clone(), + source_rect: self.source_rect, + }); + true + } + + pub(super) fn set_source_rect(&mut self, source_rect: ImagePixelRect) -> bool { + if self.crop_locked() || self.source_rect == source_rect { + return false; + } + self.source_rect = source_rect; + self.redo.clear(); + self.ready_preview = None; + self.base_cache = None; + self.desired_preview = None; + self.failed_revision = None; + true + } + + pub(super) fn invalidate_base(&mut self, fingerprint: RegionRenderFingerprint) { + if !self.cuts.is_empty() && !self.bump_revision() { + return; + } + self.base_cache = None; + self.ready_preview = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + } + + pub(super) fn mark_preview_failed(&mut self, key: &CutPreviewKey) -> bool { + if self.desired_preview.as_ref() != Some(key) { + return false; + } + self.failed_revision = Some(key.revision); + true + } +} + +pub(super) fn dominant_cut_axis(dx: f64, dy: f64) -> CutAxis { + if dy.abs() > dx.abs() { + CutAxis::Rows + } else { + CutAxis::Columns + } +} + +pub(super) fn output_display_for( + token: &ScreenSourceToken, + source_rect: ImagePixelRect, + cuts: &[CutBand], +) -> Option { + let size = output_size((source_rect.width(), source_rect.height()), cuts).ok()?; + native_extent_display(token, source_rect, size) +} + +pub(super) fn native_extent_display( + token: &ScreenSourceToken, + source_rect: ImagePixelRect, + size: (u32, u32), +) -> Option { + let rect = screen_rect_for_native_extent(token, (source_rect.x(), source_rect.y()), size)?; + Some(region_selection_from_rect(rect)) +} + +pub(super) fn region_selection_from_rect(rect: Rect) -> RegionSelection { + RegionSelection { + start: (f64::from(rect.x), f64::from(rect.y)), + end: ( + f64::from(rect.x.saturating_add(rect.width)), + f64::from(rect.y.saturating_add(rect.height)), + ), + } +} + +pub(super) fn display_contains(display: RegionSelection, point: (f64, f64)) -> bool { + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + point.0 >= left && point.0 < right && point.1 >= top && point.1 < bottom +} + +pub(super) fn logical_to_output_point( + display: RegionSelection, + output_size: (u32, u32), + point: (f64, f64), +) -> Option { + if output_size.0 == 0 || output_size.1 == 0 || !point.0.is_finite() || !point.1.is_finite() { + return None; + } + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + let width = right - left; + let height = bottom - top; + if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() { + return None; + } + let x = ((point.0 - left) / width) * f64::from(output_size.0); + let y = ((point.1 - top) / height) * f64::from(output_size.1); + if !x.is_finite() || !y.is_finite() { + return None; + } + Some(ImagePoint::new( + x.clamp(0.0, f64::from(output_size.0)), + y.clamp(0.0, f64::from(output_size.1)), + )) +} + +fn quantized_cut( + axis: CutAxis, + display: RegionSelection, + output_size: (u32, u32), + start: (f64, f64), + current: (f64, f64), +) -> Option { + let first = logical_to_output_point(display, output_size, start)?; + let second = logical_to_output_point(display, output_size, current)?; + let span = pixel_span(first, second, output_size)?; + match axis { + CutAxis::Columns => { + CutBand::from_unordered_edges(axis, span.x(), span.x().checked_add(span.width())?).ok() + } + CutAxis::Rows => { + CutBand::from_unordered_edges(axis, span.y(), span.y().checked_add(span.height())?).ok() + } + } +} + +pub(super) fn cut_band_display( + display: RegionSelection, + output_size: (u32, u32), + axis: CutAxis, + start: u32, + end: u32, +) -> Option { + if end <= start || output_size.0 == 0 || output_size.1 == 0 { + return None; + } + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + let width = right - left; + let height = bottom - top; + match axis { + CutAxis::Columns => { + let x0 = left + f64::from(start) * width / f64::from(output_size.0); + let x1 = left + f64::from(end) * width / f64::from(output_size.0); + Some(RegionSelection { + start: (x0, top), + end: (x1, bottom), + }) + } + CutAxis::Rows => { + let y0 = top + f64::from(start) * height / f64::from(output_size.1); + let y1 = top + f64::from(end) * height / f64::from(output_size.1); + Some(RegionSelection { + start: (left, y0), + end: (right, y1), + }) + } + } +} + +fn retire_cut_drag_owner(input: &mut InputState, owner: Option) { + if let Some(owner) = owner { + let _ = input.finish_region_review_move(owner); + } +} + +fn apply_cut_history_change( + edits: &mut Option, + input: &mut InputState, + change: impl FnOnce(&mut RegionReviewEdits) -> bool, +) -> bool { + let owner = edits + .as_ref() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + let Some(edits) = edits.as_mut() else { + return false; + }; + if !change(edits) { + return false; + } + retire_cut_drag_owner(input, owner); + true +} + +impl WaylandState { + pub(super) fn region_review_edits(&self) -> Option<&RegionReviewEdits> { + self.data.region_review_edits.as_ref() + } + + pub(super) fn region_review_edits_mut(&mut self) -> Option<&mut RegionReviewEdits> { + self.data.region_review_edits.as_mut() + } + + pub(in crate::backend::wayland) fn region_review_crop_locked(&self) -> bool { + self.region_review_edits() + .is_some_and(RegionReviewEdits::crop_locked) + } + + pub(in crate::backend::wayland) fn region_review_loupe_suppressed(&self) -> bool { + self.region_review_edits() + .is_some_and(RegionReviewEdits::loupe_suppressed) + } + + pub(in crate::backend::wayland) fn region_cut_displayed_selection( + &self, + ) -> Option { + let edits = self.region_review_edits()?; + if let Some(preview) = &edits.ready_preview { + return Some(preview.display); + } + let token = self.region_picker_source_token()?; + output_display_for(&token, edits.source_rect, &[]) + } + + pub(in crate::backend::wayland) fn region_cut_availability(&self) -> RegionActionAvailability { + self.region_review_edits() + .map(RegionReviewEdits::availability) + .unwrap_or_default() + } + + pub(in crate::backend::wayland) fn region_cut_status(&self) -> Option { + self.region_review_edits() + .and_then(RegionReviewEdits::status) + } + + pub(in crate::backend::wayland) fn region_cut_mode_armed(&self) -> bool { + self.region_review_edits() + .is_some_and(|edits| edits.mode == CutMode::Armed) + } + + pub(super) fn create_region_review_edits(&mut self, rect: ImagePixelRect) { + self.data.region_review_edits = + review_edits_for_active_region(self.data.active_screen_region, rect); + } + + pub(super) fn mark_region_cut_ui_dirty(&mut self) { + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + + pub(in crate::backend::wayland) fn apply_region_review_edit( + &mut self, + action: crate::ui::RegionAction, + ) -> bool { + match action { + crate::ui::RegionAction::CutBand => self.toggle_region_cut_mode(), + crate::ui::RegionAction::UndoCut => self.undo_region_cut(), + crate::ui::RegionAction::RedoCut => self.redo_region_cut(), + crate::ui::RegionAction::ResetCuts => self.reset_region_cuts(), + crate::ui::RegionAction::ToggleIncludeDrawings => { + self.toggle_region_picker_include_drawings() + } + crate::ui::RegionAction::Copy + | crate::ui::RegionAction::Save + | crate::ui::RegionAction::Both + | crate::ui::RegionAction::Board => false, + } + } + + fn retire_region_cut_drag_owner(&mut self, owner: Option) { + retire_cut_drag_owner(&mut self.input_state, owner); + } + + fn toggle_region_cut_mode(&mut self) -> bool { + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + let owner = edits.toggle_mode(); + self.retire_region_cut_drag_owner(owner); + self.mark_region_cut_ui_dirty(); + true + } + + fn undo_region_cut(&mut self) -> bool { + let Some(fingerprint) = self.current_region_fingerprint() else { + return false; + }; + if !apply_cut_history_change( + &mut self.data.region_review_edits, + &mut self.input_state, + |edits| edits.undo(fingerprint), + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + true + } + + fn redo_region_cut(&mut self) -> bool { + let Some(fingerprint) = self.current_region_fingerprint() else { + return false; + }; + if !apply_cut_history_change( + &mut self.data.region_review_edits, + &mut self.input_state, + |edits| edits.redo(fingerprint), + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + true + } + + fn reset_region_cuts(&mut self) -> bool { + if !apply_cut_history_change( + &mut self.data.region_review_edits, + &mut self.input_state, + RegionReviewEdits::reset, + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn try_begin_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let Some(display) = self.region_cut_displayed_selection() else { + return false; + }; + if !display_contains(display, point) { + return false; + } + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + if !edits.begin_drag(owner, point) { + return false; + } + if !self.input_state.begin_region_review_move(owner) { + if let Some(edits) = self.region_review_edits_mut() { + edits.drag = None; + } + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn update_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + if !edits.update_drag(owner, point) { + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn finish_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + if !self + .region_review_edits() + .and_then(|edits| edits.drag) + .is_some_and(|drag| drag.owner == owner) + { + return false; + } + let Some(display) = self.region_cut_displayed_selection() else { + self.abandon_region_cut_drag(owner); + return true; + }; + let Some(fingerprint) = self.current_region_fingerprint() else { + self.abandon_region_cut_drag(owner); + return true; + }; + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + let commit = edits.finish_drag(owner, point, display, fingerprint); + let _ = self.input_state.finish_region_review_move(owner); + match commit { + CutCommit::Applied => { + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + } + CutCommit::RejectedFullAxis => { + self.input_state.push_toast( + crate::input::state::ToastPriority::Info, + "capture", + crate::input::state::Toast::warning( + "That cut would remove the entire remaining image.", + ), + ); + self.mark_region_cut_ui_dirty(); + } + CutCommit::None => self.mark_region_cut_ui_dirty(), + } + true + } + + pub(in crate::backend::wayland) fn abandon_region_cut_drag( + &mut self, + owner: RegionInputSource, + ) -> bool { + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + let Some(drag) = edits.drag else { + return false; + }; + if drag.owner != owner { + return false; + } + edits.drag = None; + let _ = self.input_state.finish_region_review_move(owner); + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn handle_region_cut_escape(&mut self) -> bool { + let owner = self + .region_review_edits() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + let Some(edits) = self.region_review_edits_mut() else { + return false; + }; + if !edits.disarm_mode() { + return false; + } + if let Some(owner) = owner { + let _ = self.input_state.finish_region_review_move(owner); + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(super) fn sync_region_review_source_rect(&mut self) { + let Some(rect) = self.region_review_rect() else { + return; + }; + let Some(edits) = self.region_review_edits_mut() else { + return; + }; + if edits.set_source_rect(rect) { + self.mark_region_cut_ui_dirty(); + } + } + + pub(in crate::backend::wayland) fn region_cut_preview_pixels( + &self, + ) -> Option<&crate::screen_pixels::PackedArgb32> { + self.region_review_edits() + .and_then(|edits| edits.ready_preview.as_ref()) + .map(|preview| preview.pixels.as_ref()) + } + + pub(in crate::backend::wayland) fn region_cut_drag_overlay( + &self, + ) -> Option<(CutAxis, RegionSelection)> { + let edits = self.region_review_edits()?; + let drag = edits.drag?; + let axis = drag.axis?; + let display = self.region_cut_displayed_selection()?; + let output = edits.output_size()?; + let band = quantized_cut(axis, display, output, drag.start, drag.current)?; + debug_assert_eq!(band.axis(), axis); + cut_band_display(display, output, axis, band.start(), band.end()).map(|band| (axis, band)) + } + + pub(in crate::backend::wayland) fn consume_region_review_press( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> RegionReviewPress { + if !self.input_state.region_state().is_review() { + return RegionReviewPress::NotReview; + } + if self.region_review_bar_contains(point) { + let suppress_release = if let Some(action) = self.region_review_action_at(point) { + let terminal = action.is_terminal(); + self.submit_region_review_action(action); + terminal + } else { + false + }; + return RegionReviewPress::Consumed { suppress_release }; + } + if self.try_begin_region_cut_drag(owner, point) { + return RegionReviewPress::Consumed { + suppress_release: false, + }; + } + if self.region_review_crop_locked() || self.region_cut_mode_armed() { + return RegionReviewPress::Consumed { + suppress_release: false, + }; + } + RegionReviewPress::Fallthrough + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RegionReviewPress { + NotReview, + Consumed { suppress_release: bool }, + Fallthrough, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::state::screen_image::ScreenImageKind; + use crate::capture::CutAxis; + use wayland_client::protocol::wl_output::Transform; + + fn token() -> ScreenSourceToken { + ScreenSourceToken { + output_id: 1, + output_layout_generation: 1, + kind: ScreenImageKind::Frozen, + image_generation: 1, + image_size: (8, 8), + stride: 32, + surface: (8, 8), + output_scale: 1, + output_transform: Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } + } + + fn fingerprint(rect: ImagePixelRect) -> RegionRenderFingerprint { + RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation: 1, + source: token(), + }, + source_rect: rect, + } + } + + fn edits() -> RegionReviewEdits { + let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); + RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + ) + } + + fn display() -> RegionSelection { + RegionSelection { + start: (0.0, 0.0), + end: (8.0, 8.0), + } + } + + #[test] + fn arming_does_not_change_history() { + let mut edits = edits(); + edits.toggle_mode(); + assert_eq!(edits.mode, CutMode::Armed); + assert!(edits.cuts.is_empty()); + edits.toggle_mode(); + assert_eq!(edits.mode, CutMode::Idle); + assert!(edits.cuts.is_empty()); + } + + #[test] + fn sub_threshold_drag_commits_nothing() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (3.0, 1.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (3.0, 1.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::None + ); + assert!(edits.cuts.is_empty()); + } + + #[test] + fn axis_locks_once_past_the_threshold() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 1.0))); + assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); + assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 20.0))); + assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); + } + + #[test] + fn wrong_owner_cannot_update_or_finish_a_drag() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(!edits.update_drag(RegionInputSource::Touch, (6.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Touch, + (6.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::None + ); + assert!(edits.drag.is_some()); + } + + #[test] + fn valid_commit_appends_clears_redo_and_increments_revision() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::Applied + ); + assert_eq!(edits.cuts.len(), 1); + assert!(edits.redo.is_empty()); + assert_eq!(edits.revision, 1); + assert!(!edits.preview_is_current()); + } + + #[test] + fn full_axis_commit_is_rejected_without_a_revision_change() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (8.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (8.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::RejectedFullAxis + ); + assert!(edits.cuts.is_empty()); + assert_eq!(edits.revision, 0); + } + + #[test] + fn undo_redo_and_new_commit_clear_redo() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + assert!(edits.undo(fingerprint.clone())); + assert!(edits.cuts.is_empty()); + assert_eq!(edits.redo.len(), 1); + assert!(edits.redo(fingerprint.clone())); + assert_eq!(edits.cuts.len(), 1); + assert!(edits.undo(fingerprint.clone())); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint + ), + CutCommit::Applied + ); + assert!(edits.redo.is_empty()); + } + + #[test] + fn undo_and_redo_abandon_an_in_flight_drag() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint.clone()); + let desired = edits.desired_preview.clone().unwrap(); + edits.ready_preview = Some(RegionCutPreview { + key: desired, + pixels: std::sync::Arc::new( + crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), + ), + display: display(), + }); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.undo(fingerprint.clone())); + assert!(edits.drag.is_none()); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint.clone() + ), + CutCommit::None + ); + + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.redo(fingerprint.clone())); + assert!(edits.drag.is_none()); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint + ), + CutCommit::None + ); + } + + #[test] + fn undo_with_nothing_to_undo_leaves_an_in_flight_drag() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(!edits.undo(fingerprint(edits.source_rect))); + assert!(edits.drag.is_some()); + } + + fn review_input() -> crate::input::InputState { + let mut input = crate::input::state::test_support::make_test_input_state(); + input.activate_region_review( + crate::input::state::RegionPurposeTag::CaptureInteractive, + 1, + display(), + ); + input + } + + fn edits_with_current_preview() -> RegionReviewEdits { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint); + let desired = edits.desired_preview.clone().unwrap(); + edits.ready_preview = Some(RegionCutPreview { + key: desired, + pixels: std::sync::Arc::new( + crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), + ), + display: display(), + }); + edits + } + + #[test] + fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() { + for owner in [ + RegionInputSource::Pointer, + RegionInputSource::Touch, + RegionInputSource::Stylus, + ] { + let mut input = review_input(); + let mut edits = Some(edits_with_current_preview()); + edits.as_mut().unwrap().toggle_mode(); + assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); + assert!(input.begin_region_review_move(owner)); + assert!(input.region_selection_is_owned_by(owner)); + + let fingerprint = fingerprint(edits.as_ref().unwrap().source_rect); + assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + edits.undo(fingerprint.clone()) + })); + assert!(edits.as_ref().unwrap().drag.is_none()); + assert!( + !input.region_selection_is_owned_by(owner), + "{owner:?} must be retired before release" + ); + assert_eq!( + edits.as_mut().unwrap().finish_drag( + owner, + (7.0, 0.0), + display(), + fingerprint.clone() + ), + CutCommit::None, + "{owner:?} release must not commit after undo" + ); + assert!(!input.finish_region_review_move(owner)); + + assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); + assert!(input.begin_region_review_move(owner)); + assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + edits.redo(fingerprint.clone()) + })); + assert!(edits.as_ref().unwrap().drag.is_none()); + assert!(!input.region_selection_is_owned_by(owner)); + assert_eq!( + edits + .as_mut() + .unwrap() + .finish_drag(owner, (7.0, 0.0), display(), fingerprint), + CutCommit::None + ); + } + } + + #[test] + fn toggling_cut_mode_off_during_a_drag_returns_the_owner() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert_eq!(edits.toggle_mode(), Some(RegionInputSource::Pointer)); + assert_eq!(edits.mode, CutMode::Idle); + assert!(edits.drag.is_none()); + } + + #[test] + fn revision_exhaustion_leaves_reset_and_invalidate_untouched() { + let mut edits = edits(); + let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap(); + edits.cuts.push(cut); + edits.revision = u64::MAX; + edits.failed_revision = Some(u64::MAX); + assert!(!edits.reset()); + assert_eq!(edits.cuts, [cut]); + assert_eq!(edits.failed_revision, Some(u64::MAX)); + + let fingerprint = fingerprint(edits.source_rect); + edits.invalidate_base(fingerprint); + assert_eq!(edits.cuts, [cut]); + assert_eq!(edits.failed_revision, Some(u64::MAX)); + assert_eq!(edits.revision, u64::MAX); + } + + #[test] + fn undoing_the_last_cut_unlocks_the_crop() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + assert!(edits.crop_locked()); + assert!(edits.undo(fingerprint)); + assert!(!edits.crop_locked()); + } + + #[test] + fn a_failed_current_preview_stays_failed_until_the_revision_changes() { + let mut edits = edits(); + let desired = CutPreviewKey { + fingerprint: fingerprint(edits.source_rect), + revision: 3, + cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], + }; + edits.cuts = desired.cuts.clone(); + edits.revision = 3; + edits.desired_preview = Some(desired.clone()); + assert!(edits.mark_preview_failed(&desired)); + assert!(edits.current_preview_failed()); + edits.failed_revision = None; + edits.revision = 4; + edits.desired_preview = Some(CutPreviewKey { + revision: 4, + ..desired + }); + assert!(!edits.current_preview_failed()); + } + + #[test] + fn reset_clears_history_and_unlocks_the_crop() { + let mut edits = edits(); + edits.cuts.push(CutBand::new(CutAxis::Rows, 1, 2).unwrap()); + edits + .redo + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.mode = CutMode::Armed; + assert!(edits.reset()); + assert!(edits.cuts.is_empty()); + assert!(edits.redo.is_empty()); + assert!(!edits.crop_locked()); + assert!(edits.preview_is_current()); + } + + #[test] + fn source_rect_cannot_change_while_cuts_exist() { + let mut edits = edits(); + let next = ImagePixelRect::new(1, 1, 4, 4, (8, 8)).unwrap(); + assert!(edits.set_source_rect(next)); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + assert!(!edits.set_source_rect(ImagePixelRect::new(0, 0, 4, 4, (8, 8)).unwrap())); + assert_eq!(edits.source_rect, next); + } + + #[test] + fn cut_start_is_rejected_while_preview_is_pending() { + let mut edits = edits(); + edits.toggle_mode(); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint(edits.source_rect)); + assert!(!edits.preview_is_current()); + assert!(!edits.can_start_cut_drag()); + } + + #[test] + fn loupe_is_suppressed_when_armed_or_cuts_exist() { + let mut edits = edits(); + assert!(!edits.loupe_suppressed()); + edits.toggle_mode(); + assert!(edits.loupe_suppressed()); + edits.toggle_mode(); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + assert!(edits.loupe_suppressed()); + } + + #[test] + fn column_cut_preserves_top_left_and_height() { + let token = token(); + let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); + let full = output_display_for(&token, rect, &[]).unwrap(); + let cut = output_display_for( + &token, + rect, + &[CutBand::new(CutAxis::Columns, 2, 4).unwrap()], + ) + .unwrap(); + assert_eq!(cut.start, full.start); + assert_eq!(cut.end.1, full.end.1); + assert!(cut.end.0 < full.end.0); + } + + #[test] + fn dominant_axis_ties_choose_columns() { + assert_eq!(dominant_cut_axis(4.0, 4.0), CutAxis::Columns); + assert_eq!(dominant_cut_axis(4.0, 5.0), CutAxis::Rows); + } + + #[test] + fn pointer_edges_map_to_the_inclusive_pixel_edge_domain() { + let display = display(); + assert_eq!( + logical_to_output_point(display, (8, 8), (0.0, 0.0)).map(|point| (point.x, point.y)), + Some((0.0, 0.0)) + ); + assert_eq!( + logical_to_output_point(display, (8, 8), (8.0, 8.0)).map(|point| (point.x, point.y)), + Some((8.0, 8.0)) + ); + let clamped = logical_to_output_point(display, (8, 8), (-2.0, 20.0)).unwrap(); + assert_eq!((clamped.x, clamped.y), (0.0, 8.0)); + } + + #[test] + fn composed_board_origin_stays_put_while_size_contracts() { + let source = crate::canvas_export::CanvasExportRect::new(10.0, 20.0, 80.0, 40.0).unwrap(); + let composed = + crate::backend::wayland::state::region_capture::world_rect_for_composed_region( + source, + (8, 8), + (6, 4), + ) + .unwrap(); + assert_eq!(composed.x, 10.0); + assert_eq!(composed.y, 20.0); + assert_eq!(composed.width, 60.0); + assert_eq!(composed.height, 20.0); + } +} diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs index 1b832f6f..df793c9d 100644 --- a/src/backend/wayland/state/region_capture/delivery.rs +++ b/src/backend/wayland/state/region_capture/delivery.rs @@ -1,17 +1,13 @@ -use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; use crate::capture::{ CaptureDestination, ImageFormatMetadata, ImageOperationKind, RenderImageRequest, - RenderedImageDeliveryRequest, + RenderedImageDeliveryRequest, output_size as band_cut_output_size, }; use crate::input::state::{BoardPasteTarget, RegionPurposeTag, Toast, ToastPriority}; use crate::screen_pixels::{EmbeddedImageLimits, ImagePixelRect}; use crate::ui::RegionAction; use super::super::capture::should_exit_after_capture; -use super::super::screen_image::{ - CropError, copy_image_rect, displayed_screen_image, screen_source_is, - shared_displayed_screen_image, -}; +use super::render::{RegionRenderRequest, region_render_job}; use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionSelectionFinalize}; use crate::backend::wayland::state::WaylandState; @@ -38,35 +34,6 @@ pub(super) fn region_delivery_request( } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum RegionSubmit { - Deliver(CaptureDestination), - Board(BoardPasteTarget), -} - -/// What a submission renders from. One or the other, never both and never -/// neither, so the render job cannot be handed an impossible pair. -#[derive(Debug)] -pub(super) enum RegionRenderSource { - /// Committed board drawings composited over the crop, on the worker. - Annotated(Box), - /// The checked crop taken on the event loop. - Raw(crate::screen_pixels::PackedArgb32), -} - -/// The PNG job for a submission. Shared by every destination, so what Copy -/// writes and what Board pastes can only differ by the Review toggle. -pub(super) fn region_render_job(source: RegionRenderSource) -> crate::capture::ImageRenderJob { - match source { - RegionRenderSource::Annotated(snapshot) => { - Box::new(move || crate::canvas_export::render_canvas_region_png(*snapshot)) - } - RegionRenderSource::Raw(pixels) => { - Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels)) - } - } -} - pub(super) const fn review_delivery_destination( action: RegionAction, ) -> Option { @@ -74,10 +41,21 @@ pub(super) const fn review_delivery_destination( RegionAction::Copy => Some(CaptureDestination::ClipboardOnly), RegionAction::Save => Some(CaptureDestination::FileOnly), RegionAction::Both => Some(CaptureDestination::ClipboardAndFile), - RegionAction::Board | RegionAction::ToggleIncludeDrawings => None, + RegionAction::Board + | RegionAction::CutBand + | RegionAction::UndoCut + | RegionAction::RedoCut + | RegionAction::ResetCuts + | RegionAction::ToggleIncludeDrawings => None, } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum RegionSubmit { + Deliver(CaptureDestination), + Board(BoardPasteTarget), +} + impl WaylandState { pub(in crate::backend::wayland) fn region_review_action_at( &self, @@ -88,7 +66,7 @@ impl WaylandState { } let selection = self.region_selection_geometry()?.display_selection(); crate::ui::RegionActionBar::place(selection, (self.surface.width(), self.surface.height())) - .hit(point) + .enabled_hit(point, self.region_cut_availability()) } /// Whether the pointer sits over the reviewed rectangle — the area a press @@ -131,18 +109,35 @@ impl WaylandState { &mut self, action: RegionAction, ) -> bool { - if action == RegionAction::ToggleIncludeDrawings { - return self.toggle_region_picker_include_drawings(); + if !action.is_terminal() { + return self.apply_region_review_edit(action); + } + if !self.region_cut_availability().allows(action) { + return true; } let Some(rect) = self.region_review_rect() else { return false; }; + let cuts = self + .region_review_edits() + .map(|edits| edits.cuts.clone()) + .unwrap_or_default(); + if !self.preview_permits_submit(&cuts) { + return true; + } + let Ok(output) = band_cut_output_size((rect.width(), rect.height()), &cuts) else { + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error("Could not apply the requested cut."), + ); + return true; + }; let submit = match review_delivery_destination(action) { Some(destination) => RegionSubmit::Deliver(destination), - None => { - debug_assert_eq!(action, RegionAction::Board); - let limits = EmbeddedImageLimits::default(); - if !limits.allows_pixels(rect.width(), rect.height()) { + None => match self.board_submit_for_composed(rect, output) { + Ok(target) => RegionSubmit::Board(target), + Err(BoardSubmitError::TooLarge) => { self.input_state.push_toast( ToastPriority::Info, TOAST_SOURCE, @@ -150,28 +145,7 @@ impl WaylandState { ); return true; } - // Placement maps the authoritative image rectangle, not the - // picker's outward-rounded display rectangle: composition uses - // the same exact world rectangle, so the pasted image lands - // where the annotations were drawn instead of stretched by a - // quantization the chrome introduced. - // - // A missing source or a rectangle that will not map is a dead - // end for this capture, not a no-op: falling through silently - // would leave Review painted over a reservation nothing can - // ever complete, so it retires through the usual funnel. - let placement = match self.data.active_screen_region { - Some(ActiveScreenRegion::Ready { source, .. }) => { - super::world_rect_for_image_rect_exact( - rect, - self.board_view_offset(), - source, - ) - .and_then(super::board_bounds_for_world_rect) - } - _ => None, - }; - let Some(world_bounds) = placement else { + Err(BoardSubmitError::Unplaceable) => { self.cancel_region_capture(); self.input_state.push_toast( ToastPriority::Critical, @@ -179,19 +153,57 @@ impl WaylandState { Toast::error("Could not place that region on the board."), ); return true; - }; - RegionSubmit::Board(BoardPasteTarget { - board_id: self.input_state.boards.active_board_id().to_string(), - page_index: self.input_state.boards.active_page_index(), - page_generation: self.input_state.boards.active_page_generation(), - world_bounds, - }) - } + } + }, }; - self.submit_region_capture_with(rect, submit); + self.submit_region_capture_with(rect, cuts, submit); true } + fn preview_permits_submit(&self, cuts: &[crate::capture::CutBand]) -> bool { + cuts.is_empty() + || self + .region_review_edits() + .is_some_and(super::cut_review::RegionReviewEdits::preview_is_current) + } + + fn board_submit_for_composed( + &self, + rect: ImagePixelRect, + output: (u32, u32), + ) -> Result { + let limits = EmbeddedImageLimits::default(); + if !limits.allows_pixels(output.0, output.1) { + return Err(BoardSubmitError::TooLarge); + } + let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region else { + return Err(BoardSubmitError::Unplaceable); + }; + let Some(source_world) = + super::world_rect_for_image_rect_exact(rect, self.board_view_offset(), source) + else { + return Err(BoardSubmitError::Unplaceable); + }; + let composed = if output == (rect.width(), rect.height()) { + source_world + } else { + super::world_rect_for_composed_region( + source_world, + (rect.width(), rect.height()), + output, + ) + .ok_or(BoardSubmitError::Unplaceable)? + }; + let world_bounds = + super::board_bounds_for_world_rect(composed).ok_or(BoardSubmitError::Unplaceable)?; + Ok(BoardPasteTarget { + board_id: self.input_state.boards.active_board_id().to_string(), + page_index: self.input_state.boards.active_page_index(), + page_generation: self.input_state.boards.active_page_generation(), + world_bounds, + }) + } + /// Submit the whole displayed image and retire any in-flight drag owner. /// /// Keeping this transaction on WaylandState leaves the keyboard protocol @@ -202,6 +214,12 @@ impl WaylandState { else { return; }; + if purpose == RegionPurposeTag::CaptureInteractive + && self.input_state.region_state().is_review() + && self.region_review_crop_locked() + { + return; + } self.clear_region_window_snap(); self.retire_region_selection_owner(self.input_state.region_state().selection_owner()); match purpose { @@ -222,12 +240,16 @@ impl WaylandState { } _ => return, }; - self.submit_region_capture_with(rect, RegionSubmit::Deliver(destination)); + self.submit_region_capture_with(rect, Vec::new(), RegionSubmit::Deliver(destination)); } - fn submit_region_capture_with(&mut self, rect: ImagePixelRect, submit: RegionSubmit) { + fn submit_region_capture_with( + &mut self, + rect: ImagePixelRect, + cuts: Vec, + submit: RegionSubmit, + ) { let Some(ActiveScreenRegion::Ready { - source: token, freeze_ownership, purpose, include_drawings, @@ -240,102 +262,41 @@ impl WaylandState { if !purpose.is_capture() { return; } - let Some(source) = displayed_screen_image( - &self.zoom, - &self.frozen, - self.input_state.board_is_transparent(), - ) else { - self.cancel_region_capture_for_source_change(); - return; - }; - if !screen_source_is( - &token, - &source, - &self.zoom, - &self.frozen, - (self.surface.width(), self.surface.height()), - ) { - self.cancel_region_capture_for_source_change(); - return; - } - // Every destination honours the Review toggle, Board included. Pasting a - // composited crop onto the board it came from bakes a second, flattened - // copy of those annotations over the live shapes; the toggle is the - // control for that, so it is not overridden per destination here. - // - // The composed path validates and crops on the capture worker. The raw - // path retains the existing checked event-loop crop, but never pays - // that copy when the result would be discarded by composition. - let raw_pixels = if include_drawings { - None - } else { - match copy_image_rect(source.image, rect) { - Ok(pixels) => Some(pixels), - Err(error) => { - let message = match error { - CropError::Empty => "That selection has no screen pixels.", - CropError::OutOfBounds => "Could not read that region of the screen image.", - }; - self.cancel_region_capture(); - self.input_state.push_toast( - ToastPriority::Critical, - TOAST_SOURCE, - Toast::error(message), - ); - return; - } + let snapshot = match self.snapshot_region_render(rect, include_drawings) { + Ok(snapshot) => snapshot, + Err(crate::capture::CaptureError::Cancelled(_)) => { + self.cancel_region_capture_for_source_change(); + return; } - }; - let drawing_snapshot = include_drawings - .then(|| { - let shared_image = - shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind)?; - let (origin_x, origin_y) = self.board_view_offset(); - super::world_rect_for_image_rect_exact(rect, (origin_x, origin_y), token)?; - let logical_bounds = CanvasExportRect::new( - origin_x, - origin_y, - f64::from(token.surface.0), - f64::from(token.surface.1), - )?; - // This explicit option shares the full captured source so blur - // at a crop edge can sample the same neighboring pixels as the - // live canvas. The immutable image handle and frame snapshot - // make the worker job independent of later edits. - Some(CanvasRegionExportSnapshot { - source: CanvasRegionSource { - image: shared_image, - logical_bounds, - }, - selection: rect, - frame: self - .input_state - .boards - .active_frame() - .clone_without_history(), - text_halo_enabled: self.config.drawing.text_halo_enabled, - spotlight: crate::canvas_export::SpotlightPassSnapshot { - dim_opacity: self.input_state.spotlight_dim_opacity, - feather: self.input_state.spotlight_feather, - }, - }) - }) - .flatten(); - let render_source = match (drawing_snapshot, raw_pixels) { - (Some(snapshot), _) => RegionRenderSource::Annotated(Box::new(snapshot)), - (None, Some(pixels)) => RegionRenderSource::Raw(pixels), - (None, None) => { - // The composed path skips the event-loop crop, so a snapshot - // that could not be built leaves nothing at all to render. + Err(error) => { self.cancel_region_capture(); self.input_state.push_toast( ToastPriority::Critical, TOAST_SOURCE, - Toast::error("Could not map the selected drawings into the captured region."), + Toast::error(error.to_string()), ); return; } }; + if !cuts.is_empty() { + let fingerprint_ok = self.region_review_edits().is_some_and(|edits| { + edits.ready_preview.as_ref().is_some_and(|preview| { + preview.key.fingerprint == snapshot.fingerprint && preview.key.cuts == cuts + }) + }); + if !fingerprint_ok { + if let Some(edits) = self.region_review_edits_mut() { + edits.invalidate_base(snapshot.fingerprint); + } + self.schedule_region_cut_preview(); + self.mark_region_cut_ui_dirty(); + return; + } + } + let request = RegionRenderRequest { + source: snapshot.source, + cuts, + }; let Some(intent) = self.capture.begin_region_submission() else { self.cancel_region_capture(); @@ -351,7 +312,7 @@ impl WaylandState { self.release_owned_frozen_generation(image_generation); } - let render = region_render_job(render_source); + let render = region_render_job(request); match submit { RegionSubmit::Deliver(destination) => { self.capture.set_exit_on_success(should_exit_after_capture( @@ -393,3 +354,8 @@ impl WaylandState { } } } + +enum BoardSubmitError { + TooLarge, + Unplaceable, +} diff --git a/src/backend/wayland/state/region_capture/events.rs b/src/backend/wayland/state/region_capture/events.rs index a18143fe..698c37fc 100644 --- a/src/backend/wayland/state/region_capture/events.rs +++ b/src/backend/wayland/state/region_capture/events.rs @@ -246,3 +246,21 @@ pub(in crate::backend::wayland::state) fn finalize_region_selection_event( } RegionSelectionFinalize::Selected { purpose, rect } } + +pub(in crate::backend::wayland::state) fn finalize_region_selection_with_review_edits( + backend: &mut Option, + input_state: &mut crate::input::InputState, + review_edits: &mut Option, + owner: RegionInputSource, + logical: (f64, f64), +) -> RegionSelectionFinalize { + let was_review = input_state.region_state().is_review(); + let result = finalize_region_selection_event(backend, input_state, owner, logical); + if result == RegionSelectionFinalize::Reviewed + && (!was_review || review_edits.is_none()) + && let Some(rect) = backend.and_then(ActiveScreenRegion::selection_rect) + { + *review_edits = super::cut_review::review_edits_for_active_region(*backend, rect); + } + result +} diff --git a/src/backend/wayland/state/region_capture/render.rs b/src/backend/wayland/state/region_capture/render.rs new file mode 100644 index 00000000..79033791 --- /dev/null +++ b/src/backend/wayland/state/region_capture/render.rs @@ -0,0 +1,229 @@ +use std::sync::Arc; + +use crate::canvas_export::{CanvasRegionExportSnapshot, render_canvas_region_pixels}; +use crate::capture::{CaptureError, CutBand, apply_band_cuts, output_size as band_cut_output_size}; +use crate::screen_pixels::{ImagePixelRect, PackedArgb32, ScreenImage}; + +/// Immutable pixels a region render job may flatten, then cut. +#[derive(Debug, Clone)] +pub(super) enum RegionPixelSource { + Raw { + image: Arc, + selection: ImagePixelRect, + }, + Annotated(Box), +} + +#[derive(Debug, Clone)] +pub(super) struct RegionRenderRequest { + pub source: RegionPixelSource, + pub cuts: Vec, +} + +impl RegionPixelSource { + pub(super) fn selection(&self) -> ImagePixelRect { + match self { + Self::Raw { selection, .. } => *selection, + Self::Annotated(snapshot) => snapshot.selection, + } + } +} + +impl RegionRenderRequest { + pub(super) fn output_size(&self) -> Result<(u32, u32), CaptureError> { + let selection = self.source.selection(); + band_cut_output_size((selection.width(), selection.height()), &self.cuts) + .map_err(band_cut_error) + } +} + +pub(super) fn render_region_base_pixels( + source: RegionPixelSource, +) -> Result { + match source { + RegionPixelSource::Raw { image, selection } => { + image.copy_rect(selection).ok_or_else(|| { + CaptureError::ImageError("Could not copy the selected screen pixels.".to_string()) + }) + } + RegionPixelSource::Annotated(snapshot) => render_canvas_region_pixels(*snapshot), + } +} + +pub(super) fn compose_region_pixels( + base: PackedArgb32, + cuts: &[CutBand], +) -> Result { + if cuts.is_empty() { + return Ok(base); + } + apply_band_cuts(&base, cuts).map_err(band_cut_error) +} + +pub(super) fn compose_shared_region_pixels( + base: &Arc, + cuts: &[CutBand], +) -> Result, CaptureError> { + if cuts.is_empty() { + return Ok(Arc::clone(base)); + } + apply_band_cuts(base.as_ref(), cuts) + .map(Arc::new) + .map_err(band_cut_error) +} + +pub(super) fn render_region_pixels( + request: RegionRenderRequest, +) -> Result { + let _ = request.output_size()?; + compose_region_pixels(render_region_base_pixels(request.source)?, &request.cuts) +} + +/// The PNG job for a submission. Shared by every destination, so what Copy +/// writes and what Board pastes can only differ by the Review toggle and cuts. +pub(super) fn region_render_job(request: RegionRenderRequest) -> crate::capture::ImageRenderJob { + Box::new(move || crate::capture::png::encode_packed_argb32_png(&render_region_pixels(request)?)) +} + +fn band_cut_error(error: crate::capture::BandCutError) -> CaptureError { + CaptureError::ImageError(format!("Could not apply the requested cut: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas_export::{CanvasExportRect, CanvasRegionSource, SpotlightPassSnapshot}; + use crate::capture::CutAxis; + use crate::draw::{Frame, RED, Shape}; + + fn swatch() -> ([u32; 6], Vec) { + let ids = [ + 0xFF11_2233, + 0xFF44_5566, + 0xFF77_8899, + 0xFFAA_BBCC, + 0xFFDD_EEFF, + 0xFF01_0203, + ]; + let bytes = ids + .iter() + .copied() + .flat_map(u32::to_ne_bytes) + .collect::>(); + (ids, bytes) + } + + fn decode_png_pixels(bytes: &[u8]) -> Vec { + let png = bytes.to_vec(); + let mut surface = cairo::ImageSurface::create_from_png(&mut png.as_slice()).expect("png"); + surface.flush(); + let width = surface.width() as usize; + let height = surface.height() as usize; + let stride = surface.stride() as usize; + let data = surface.data().expect("pixels"); + let mut pixels = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let offset = y * stride + x * 4; + pixels.push(u32::from_ne_bytes( + data[offset..offset + 4].try_into().expect("pixel"), + )); + } + } + pixels + } + + #[test] + fn raw_and_annotated_jobs_share_the_cut_compose_path() { + let (ids, bytes) = swatch(); + let image = Arc::new(ScreenImage { + data: bytes.clone(), + width: 3, + height: 2, + stride: 12, + }); + let selection = ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap(); + let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap(); + let rendered = region_render_job(RegionRenderRequest { + source: RegionPixelSource::Raw { + image: Arc::clone(&image), + selection, + }, + cuts: vec![cut], + })() + .expect("raw cut"); + assert_eq!((rendered.width, rendered.height), (2, 2)); + assert_eq!( + decode_png_pixels(&rendered.bytes), + vec![ids[0], ids[2], ids[3], ids[5]] + ); + + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 10, + y: 20, + w: 3, + h: 2, + fill: true, + color: RED, + thick: 1.0, + }); + let composed = region_render_job(RegionRenderRequest { + source: RegionPixelSource::Annotated(Box::new(CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image, + logical_bounds: CanvasExportRect::new(10.0, 20.0, 3.0, 2.0).unwrap(), + }, + selection, + frame, + text_halo_enabled: true, + spotlight: SpotlightPassSnapshot { + dim_opacity: 0.0, + feather: 0.0, + }, + })), + cuts: vec![cut], + })() + .expect("annotated cut"); + assert_eq!((composed.width, composed.height), (2, 2)); + assert_ne!(composed.bytes, rendered.bytes); + } + + #[test] + fn empty_cuts_keep_the_source_crop() { + let (_, bytes) = swatch(); + let request = RegionRenderRequest { + source: RegionPixelSource::Raw { + image: Arc::new(ScreenImage { + data: bytes, + width: 3, + height: 2, + stride: 12, + }), + selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap(), + }, + cuts: Vec::new(), + }; + let rendered = region_render_job(request)().expect("raw"); + assert_eq!((rendered.width, rendered.height), (3, 2)); + } + + #[test] + fn empty_cuts_return_the_owned_base_without_copying() { + let (_, bytes) = swatch(); + let base = render_region_base_pixels(RegionPixelSource::Raw { + image: Arc::new(ScreenImage { + data: bytes, + width: 3, + height: 2, + stride: 12, + }), + selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap(), + }) + .expect("base"); + let ptr = base.data().as_ptr(); + let composed = compose_region_pixels(base, &[]).expect("empty cuts"); + assert_eq!(composed.data().as_ptr(), ptr); + assert_eq!((composed.width(), composed.height()), (3, 2)); + } +} diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs index 1efa9f89..19b949a7 100644 --- a/src/backend/wayland/state/region_capture/runtime.rs +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -82,8 +82,18 @@ impl WaylandState { return false; }; log::debug!("region picker include drawings: {checked}"); - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; + if self + .region_review_edits() + .is_some_and(|edits| !edits.cuts.is_empty()) + { + if let Some(fingerprint) = self.current_region_fingerprint() + && let Some(edits) = self.region_review_edits_mut() + { + edits.invalidate_base(fingerprint); + } + self.schedule_region_cut_preview(); + } + self.mark_region_cut_ui_dirty(); true } @@ -162,6 +172,7 @@ impl WaylandState { pub(in crate::backend::wayland::state) fn clear_screen_region_ui_only(&mut self) { self.clear_region_window_snap(); + self.data.region_review_edits = None; self.data.active_screen_region = None; self.input_state.cancel_region_ui_only(); self.debug_assert_screen_region_invariant(); @@ -195,12 +206,18 @@ impl WaylandState { if self.update_region_window_hover((x, y)) { return; } + if self.update_region_cut_drag(owner, (x, y)) { + return; + } update_region_selection_event( &mut self.data.active_screen_region, &mut self.input_state, owner, (x, y), ); + if self.input_state.region_state().is_review() { + self.sync_region_review_source_rect(); + } } pub(in crate::backend::wayland) fn region_selection_geometry( @@ -227,6 +244,17 @@ impl WaylandState { self.data .active_screen_region .and_then(ActiveScreenRegion::selection_geometry) + .and_then(|geometry| { + if !self.input_state.region_state().is_review() { + return Some(geometry); + } + let Some(display) = self.region_cut_displayed_selection() else { + return Some(geometry); + }; + let purpose = self.data.active_screen_region?.purpose(); + let rect = geometry.image_rect()?; + Some(RegionSelectionGeometry::review(purpose, rect, display)) + }) } pub(in crate::backend::wayland) fn region_measure_selection(&self) -> Option { @@ -275,6 +303,7 @@ impl WaylandState { }; self.input_state .activate_region_review(purpose, generation, display); + self.create_region_review_edits(rect); self.debug_assert_screen_region_invariant(); true } @@ -288,6 +317,9 @@ impl WaylandState { if !self.input_state.region_state().is_review() { return false; } + if self.region_review_crop_locked() { + return true; + } let Some(display) = self .data .active_screen_region @@ -297,6 +329,7 @@ impl WaylandState { return false; }; self.input_state.update_region_review_display(display); + self.sync_region_review_source_rect(); true } @@ -321,6 +354,9 @@ impl WaylandState { if !self.input_state.region_state().is_review() { return None; } + if self.region_review_crop_locked() || self.region_cut_mode_armed() { + return None; + } self.data .active_screen_region .as_ref() @@ -346,6 +382,16 @@ impl WaylandState { height: rect.height(), }); } + if self.input_state.region_state().is_review() + && let Some(size) = self + .region_review_edits() + .and_then(RegionReviewEdits::displayed_output_size) + { + return Some(RegionPickerMeasurement::Size { + width: size.0, + height: size.1, + }); + } self.data .active_screen_region .and_then(|region| region.picker_measurement(pointer)) @@ -368,6 +414,9 @@ impl WaylandState { &mut self, source: RegionInputSource, ) -> RegionOwnerLoss { + if self.abandon_region_cut_drag(source) { + return RegionOwnerLoss::Rearmed; + } region_owner_lost_event( &mut self.data.active_screen_region, &mut self.input_state, diff --git a/src/backend/wayland/state/region_capture/tests/capture_selection.rs b/src/backend/wayland/state/region_capture/tests/capture_selection.rs index c3e9a43c..61ee5d4f 100644 --- a/src/backend/wayland/state/region_capture/tests/capture_selection.rs +++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs @@ -1,5 +1,8 @@ use super::lifecycle_and_measure::ocr_region; use super::*; +use crate::backend::wayland::state::region_capture::cut_review::{ + CutCommit, CutMode, RegionRenderFingerprint, +}; pub(super) fn capture_region() -> ActiveScreenRegion { capture_region_at_scale(1.0) @@ -84,9 +87,10 @@ fn capture_press_snaps_its_anchor_and_starts_with_a_zero_pixel_span() { } #[test] -fn interactive_release_enters_integer_authoritative_review() { +fn interactive_release_enters_review_ready_for_the_first_cut_drag() { let mut backend = Some(interactive_region()); let mut input = make_test_input_state(); + let mut review_edits = None; input.activate_region(RegionPurposeTag::CaptureInteractive, 1); assert!(begin_region_selection_event( @@ -96,9 +100,10 @@ fn interactive_release_enters_integer_authoritative_review() { (10.2, 20.7), )); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut review_edits, RegionInputSource::Pointer, (30.1, 42.2), ), @@ -118,4 +123,88 @@ fn interactive_release_enters_integer_authoritative_review() { .and_then(ActiveScreenRegion::selection_geometry) .map(|geometry| geometry.display_selection()) ); + + let display = input + .region_state() + .selection() + .expect("Review displays the selected capture"); + let edits = review_edits + .as_mut() + .expect("normal interactive Review initializes cut state"); + assert_eq!(edits.mode, CutMode::Idle); + assert_eq!(edits.toggle_mode(), None); + assert!(edits.begin_drag(RegionInputSource::Pointer, (12.0, 25.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (20.0, 25.0))); + let fingerprint = RegionRenderFingerprint::Raw { + correlation: edits.correlation.clone(), + source_rect: edits.source_rect, + }; + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (20.0, 25.0), + display, + fingerprint, + ), + CutCommit::Applied + ); + assert_eq!(edits.cuts.len(), 1); +} + +#[test] +fn reselecting_before_a_cut_replaces_the_review_edit_geometry() { + let mut backend = Some(interactive_region()); + let mut input = make_test_input_state(); + let mut review_edits = None; + input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 10.0), + )); + assert_eq!( + finalize_region_selection_with_review_edits( + &mut backend, + &mut input, + &mut review_edits, + RegionInputSource::Pointer, + (30.0, 30.0), + ), + RegionSelectionFinalize::Reviewed + ); + let first_rect = review_edits + .as_ref() + .expect("first Review initializes edits") + .source_rect; + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (50.0, 50.0), + )); + assert!(!input.region_state().is_review()); + assert_eq!( + finalize_region_selection_with_review_edits( + &mut backend, + &mut input, + &mut review_edits, + RegionInputSource::Pointer, + (80.0, 90.0), + ), + RegionSelectionFinalize::Reviewed + ); + let second_rect = backend + .and_then(ActiveScreenRegion::selection_rect) + .expect("replacement Review has geometry"); + assert_ne!(second_rect, first_rect); + assert_eq!( + review_edits + .as_ref() + .expect("replacement Review refreshes edits") + .source_rect, + second_rect + ); } diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs index 381a41d8..bdb872b8 100644 --- a/src/backend/wayland/state/region_capture/tests/picker.rs +++ b/src/backend/wayland/state/region_capture/tests/picker.rs @@ -1,11 +1,14 @@ use super::*; use crate::backend::ExitAfterCaptureMode; use crate::backend::wayland::state::region_capture::delivery::{ - RegionRenderSource, region_delivery_request, region_render_job, review_delivery_destination, + region_delivery_request, review_delivery_destination, }; use crate::backend::wayland::state::region_capture::picker::{ RegionPickerEntry, legacy_region_request, region_destination, region_picker_entry, }; +use crate::backend::wayland::state::region_capture::render::{ + RegionPixelSource, RegionRenderRequest, region_render_job, +}; use crate::canvas_export::{ CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource, SpotlightPassSnapshot, }; @@ -14,7 +17,7 @@ use crate::capture::{ }; use crate::config::{Action, RegionPicker}; use crate::input::state::RegionPurposeTag; -use crate::screen_pixels::PackedArgb32; +use crate::screen_pixels::{ImagePixelRect, PackedArgb32}; use crate::ui::RegionAction; #[test] @@ -66,6 +69,10 @@ fn review_destination_labels_match_the_delivery_they_request() { review_delivery_destination(RegionAction::ToggleIncludeDrawings), None ); + assert_eq!(review_delivery_destination(RegionAction::CutBand), None); + assert_eq!(review_delivery_destination(RegionAction::UndoCut), None); + assert_eq!(review_delivery_destination(RegionAction::RedoCut), None); + assert_eq!(review_delivery_destination(RegionAction::ResetCuts), None); } #[test] @@ -88,9 +95,20 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { .iter() .flat_map(|pixel| pixel.to_ne_bytes()) .collect(); - let raw = PackedArgb32::new(3, 2, 12, bytes.clone()).expect("a 3x2 crop"); - let rendered = region_render_job(RegionRenderSource::Raw(raw))().expect("raw render"); + let rendered = region_render_job(RegionRenderRequest { + source: RegionPixelSource::Raw { + image: std::sync::Arc::new(crate::screen_pixels::ScreenImage { + data: bytes.clone(), + width: 3, + height: 2, + stride: 12, + }), + selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"), + }, + cuts: Vec::new(), + })() + .expect("raw render"); let direct = crate::capture::png::encode_packed_argb32_png( &PackedArgb32::new(3, 2, 12, bytes).expect("a 3x2 crop"), ) @@ -141,8 +159,11 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { feather: 0.0, }, }; - let composed = - region_render_job(RegionRenderSource::Annotated(Box::new(snapshot)))().expect("composed"); + let composed = region_render_job(RegionRenderRequest { + source: RegionPixelSource::Annotated(Box::new(snapshot)), + cuts: Vec::new(), + })() + .expect("composed"); assert_eq!( (composed.width, composed.height), (3, 2), @@ -155,6 +176,41 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { ); } +#[test] +fn the_render_job_cuts_after_flattening_and_keeps_composed_bytes() { + let swatch: [u32; 6] = [ + 0xFF11_2233, + 0xFF44_5566, + 0xFF77_8899, + 0xFFAA_BBCC, + 0xFFDD_EEFF, + 0xFF01_0203, + ]; + let bytes: Vec = swatch + .iter() + .flat_map(|pixel| pixel.to_ne_bytes()) + .collect(); + let cut = crate::capture::CutBand::new(crate::capture::CutAxis::Columns, 1, 2).unwrap(); + let request = RegionRenderRequest { + source: RegionPixelSource::Raw { + image: std::sync::Arc::new(crate::screen_pixels::ScreenImage { + data: bytes, + width: 3, + height: 2, + stride: 12, + }), + selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"), + }, + cuts: vec![cut], + }; + let rendered = region_render_job(request)().expect("cut render"); + assert_eq!((rendered.width, rendered.height), (2, 2)); + assert_eq!( + decode_png_pixels(&rendered.bytes), + vec![swatch[0], swatch[2], swatch[3], swatch[5]] + ); +} + fn decode_png_pixels(bytes: &[u8]) -> Vec { let mut surface = cairo::ImageSurface::create_from_png(&mut { bytes }).expect("the job produced a PNG"); diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 7f7e0398..f5795f31 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -409,7 +409,10 @@ impl WaylandState { // Grips belong to the reviewed rectangle, not the window-mode // candidate, so they follow `region_selection_geometry` rather than // the effective selection the scrim cuts out. - let resize_handles = region_state.is_review().then(|| { + let resize_handles = (region_state.is_review() + && !self.region_review_crop_locked() + && !self.region_cut_mode_armed()) + .then(|| { geometry .map(|geometry| crate::ui::RegionResizeHandles::place(geometry.display_selection())) }); @@ -421,7 +424,8 @@ impl WaylandState { .then(|| resize_handles.and_then(|handles| handles.hit(pointer))) .flatten(); let loupe_enabled = options.is_some_and(|options| options.show_loupe()) - && (region_state.is_selecting() || region_state.is_review()); + && (region_state.is_selecting() || region_state.is_review()) + && !self.region_review_loupe_suppressed(); let loupe_source = if loupe_enabled { self.region_picker_source_token().and_then(|token| { let source = displayed_screen_image( @@ -482,6 +486,20 @@ impl WaylandState { action_bar, hovered_action, include_drawings: self.region_picker_include_drawings(), + cut: crate::ui::RegionCaptureCutVisual { + preview: self.region_cut_preview_pixels().and_then(|pixels| { + Some(crate::ui::RegionCutPreviewVisual { + pixels, + display: self.region_cut_displayed_selection()?, + }) + }), + drag: self + .region_cut_drag_overlay() + .map(|(axis, band)| crate::ui::RegionCutDragVisual { axis, band }), + availability: self.region_cut_availability(), + cut_armed: self.region_cut_mode_armed(), + status: self.region_cut_status(), + }, window, }, |image_x, image_y| { diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs index 4a3ee5a1..3fee52fa 100644 --- a/src/backend/wayland/state/screen_image.rs +++ b/src/backend/wayland/state/screen_image.rs @@ -263,6 +263,43 @@ pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImageP .expect("a non-empty image rectangle must map to a non-empty screen rectangle") } +/// Map a native-pixel extent whose origin is a source-image point into logical +/// screen space. The extent is not a second source crop; it only reuses the +/// source token's image-to-logical scale, including fractional output scale +/// and Zoom transforms. +pub(super) fn screen_rect_for_native_extent( + token: &ScreenSourceToken, + origin: (u32, u32), + native_size: (u32, u32), +) -> Option { + if native_size.0 == 0 + || native_size.1 == 0 + || token.image_size.0 == 0 + || token.image_size.1 == 0 + { + return None; + } + let right = origin.0.checked_add(native_size.0)?; + let bottom = origin.1.checked_add(native_size.1)?; + let first = screen_point_for_image_point( + token, + ImagePoint::new(f64::from(origin.0), f64::from(origin.1)), + ); + let second = + screen_point_for_image_point(token, ImagePoint::new(f64::from(right), f64::from(bottom))); + if ![first.0, first.1, second.0, second.1] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + let left = first.0.min(second.0).floor() as i32; + let top = first.1.min(second.1).floor() as i32; + let right = first.0.max(second.0).ceil() as i32; + let bottom = first.1.max(second.1).ceil() as i32; + Rect::from_min_max(left, top, right, bottom) +} + #[allow(dead_code)] // The empty-span contract is exercised by mapping tests. pub(super) fn screen_rect_for_pixel_span( token: &ScreenSourceToken, @@ -321,49 +358,7 @@ pub(super) fn copy_image_rect( if source.width == 0 || source.height == 0 { return Err(CropError::Empty); } - if rect - .x() - .checked_add(rect.width()) - .ok_or(CropError::OutOfBounds)? - > source.width - || rect - .y() - .checked_add(rect.height()) - .ok_or(CropError::OutOfBounds)? - > source.height - { - return Err(CropError::OutOfBounds); - } - let source_stride = usize::try_from(source.stride).map_err(|_| CropError::OutOfBounds)?; - let row_bytes = usize::try_from(rect.width()) - .ok() - .and_then(|width| width.checked_mul(4)) - .ok_or(CropError::OutOfBounds)?; - let total_bytes = usize::try_from(rect.height()) - .ok() - .and_then(|height| height.checked_mul(row_bytes)) - .ok_or(CropError::OutOfBounds)?; - let stride = i32::try_from(row_bytes).map_err(|_| CropError::OutOfBounds)?; - - let mut data = Vec::with_capacity(total_bytes); - let start_column = usize::try_from(rect.x()) - .ok() - .and_then(|x| x.checked_mul(4)) - .ok_or(CropError::OutOfBounds)?; - for row in 0..rect.height() { - let source_row = usize::try_from(rect.y().saturating_add(row)) - .ok() - .and_then(|row| row.checked_mul(source_stride)) - .ok_or(CropError::OutOfBounds)?; - let start = source_row - .checked_add(start_column) - .ok_or(CropError::OutOfBounds)?; - let end = start.checked_add(row_bytes).ok_or(CropError::OutOfBounds)?; - data.extend_from_slice(source.data.get(start..end).ok_or(CropError::OutOfBounds)?); - } - - crate::screen_pixels::PackedArgb32::new(rect.width(), rect.height(), stride, data) - .ok_or(CropError::OutOfBounds) + source.copy_rect(rect).ok_or(CropError::OutOfBounds) } #[cfg(test)] diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index 429836a0..0d9df223 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -13,7 +13,9 @@ pub use pdf::{ render_board_pdf, resolve_pdf_page_layout, }; pub use png::{BoardExportSnapshot, CanvasExportSnapshot, CanvasExportViewport, render_canvas_png}; -pub(crate) use region::{CanvasRegionExportSnapshot, CanvasRegionSource, render_canvas_region_png}; +pub(crate) use region::{ + CanvasRegionExportSnapshot, CanvasRegionSource, render_canvas_region_pixels, +}; #[cfg(test)] mod tests { diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index 6b8bbdff..8140ab7f 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -24,7 +24,7 @@ pub struct CanvasPageExportSnapshot { } /// Spotlight appearance carried into an export. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct SpotlightPassSnapshot { pub dim_opacity: f64, pub feather: f64, diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index e2715a70..4343525d 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -4,8 +4,9 @@ use crate::canvas_export::page::{ CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, ExportBackdrop, SpotlightPassSnapshot, draw_canvas_page_region, }; -use crate::canvas_export::png::encode_surface_png; -use crate::capture::{CaptureError, RenderedImage}; +use crate::capture::CaptureError; +#[cfg(test)] +use crate::capture::RenderedImage; use crate::draw::Frame; use crate::screen_pixels::{ImagePixelRect, PackedArgb32, ScreenImage}; @@ -43,37 +44,9 @@ impl CanvasRegionSource { } fn copy_selection(&self, selection: ImagePixelRect) -> Result { - let source_stride = usize::try_from(self.image.stride) - .map_err(|_| CaptureError::ImageError("Region source stride is invalid".to_string()))?; - let row_bytes = usize::try_from(selection.width()) - .ok() - .and_then(|width| width.checked_mul(4)) - .ok_or_else(|| CaptureError::ImageError("Region row size overflow".to_string()))?; - let target_stride = i32::try_from(row_bytes) - .map_err(|_| CaptureError::ImageError("Region stride is too large".to_string()))?; - let capacity = row_bytes - .checked_mul(selection.height() as usize) - .ok_or_else(|| CaptureError::ImageError("Region buffer size overflow".to_string()))?; - let start_column = usize::try_from(selection.x()) - .ok() - .and_then(|x| x.checked_mul(4)) - .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; - let mut data = Vec::with_capacity(capacity); - for row in 0..selection.height() { - let start = usize::try_from(selection.y().saturating_add(row)) - .ok() - .and_then(|row| row.checked_mul(source_stride)) - .and_then(|row| row.checked_add(start_column)) - .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; - let end = start - .checked_add(row_bytes) - .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; - data.extend_from_slice(self.image.data.get(start..end).ok_or_else(|| { - CaptureError::ImageError("Region leaves the captured image".to_string()) - })?); - } - PackedArgb32::new(selection.width(), selection.height(), target_stride, data) - .ok_or_else(|| CaptureError::ImageError("Region pixels are invalid".to_string())) + self.image + .copy_rect(selection) + .ok_or_else(|| CaptureError::ImageError("Region leaves the captured image".to_string())) } fn magnifier_working_selection( @@ -135,9 +108,58 @@ impl CanvasRegionSource { } } -pub(crate) fn render_canvas_region_png( +fn packed_argb32_from_surface( + surface: &mut cairo::ImageSurface, + subject: &str, +) -> Result { + surface.flush(); + let width = u32::try_from(surface.width()) + .map_err(|_| CaptureError::ImageError(format!("{subject} width is too large")))?; + let height = u32::try_from(surface.height()) + .map_err(|_| CaptureError::ImageError(format!("{subject} height is too large")))?; + let stride = surface.stride(); + let data = surface.data().map_err(|err| { + CaptureError::ImageError(format!("Failed to read {subject} pixels: {err}")) + })?; + let row_bytes = usize::try_from(width) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| CaptureError::ImageError(format!("{subject} row size overflow")))?; + let tight_stride = i32::try_from(row_bytes) + .map_err(|_| CaptureError::ImageError(format!("{subject} stride is too large")))?; + let source_stride = usize::try_from(stride) + .map_err(|_| CaptureError::ImageError(format!("{subject} stride is invalid")))?; + let packed = if source_stride == row_bytes { + PackedArgb32::new(width, height, tight_stride, data.to_vec()) + } else { + let height_usize = usize::try_from(height) + .map_err(|_| CaptureError::ImageError(format!("{subject} height is too large")))?; + let length = row_bytes + .checked_mul(height_usize) + .ok_or_else(|| CaptureError::ImageError(format!("{subject} buffer size overflow")))?; + let mut tight = Vec::new(); + tight + .try_reserve(length) + .map_err(|_| CaptureError::ImageError(format!("{subject} buffer size overflow")))?; + for row in 0..height_usize { + let start = row + .checked_mul(source_stride) + .ok_or_else(|| CaptureError::ImageError(format!("{subject} offset overflow")))?; + let end = start + .checked_add(row_bytes) + .ok_or_else(|| CaptureError::ImageError(format!("{subject} offset overflow")))?; + tight.extend_from_slice(data.get(start..end).ok_or_else(|| { + CaptureError::ImageError(format!("{subject} pixels are truncated")) + })?); + } + PackedArgb32::new(width, height, tight_stride, tight) + }; + packed.ok_or_else(|| CaptureError::ImageError(format!("{subject} pixels are invalid"))) +} + +pub(crate) fn render_canvas_region_pixels( snapshot: CanvasRegionExportSnapshot, -) -> Result { +) -> Result { let working_selection = snapshot .source .magnifier_working_selection(snapshot.selection, &snapshot.frame) @@ -154,7 +176,7 @@ pub(crate) fn render_canvas_region_png( let height = i32::try_from(working_height) .map_err(|_| CaptureError::ImageError("Region height is too large".to_string()))?; let stride = pixels.stride(); - let surface = cairo::ImageSurface::create_for_data( + let mut surface = cairo::ImageSurface::create_for_data( pixels.into_data(), cairo::Format::ARgb32, width, @@ -198,12 +220,12 @@ pub(crate) fn render_canvas_region_png( drop(ctx); if working_selection == snapshot.selection { - return encode_surface_png(&surface, "region"); + return packed_argb32_from_surface(&mut surface, "region"); } let output_width = snapshot.selection.width(); let output_height = snapshot.selection.height(); - let output = cairo::ImageSurface::create( + let mut output = cairo::ImageSurface::create( cairo::Format::ARgb32, i32::try_from(output_width) .map_err(|_| CaptureError::ImageError("Region width is too large".to_string()))?, @@ -224,7 +246,14 @@ pub(crate) fn render_canvas_region_png( .map_err(|err| CaptureError::ImageError(format!("Failed to paint region crop: {err}")))?; drop(crop); - encode_surface_png(&output, "region") + packed_argb32_from_surface(&mut output, "region") +} + +#[cfg(test)] +pub(crate) fn render_canvas_region_png( + snapshot: CanvasRegionExportSnapshot, +) -> Result { + crate::capture::png::encode_packed_argb32_png(&render_canvas_region_pixels(snapshot)?) } #[cfg(test)] @@ -605,4 +634,93 @@ mod tests { } } } + + fn pixel_at(pixels: &PackedArgb32, x: u32, y: u32) -> u32 { + let offset = y as usize * pixels.stride() as usize + x as usize * 4; + u32::from_ne_bytes(pixels.data()[offset..offset + 4].try_into().expect("pixel")) + } + + #[test] + fn no_cut_png_bytes_match_the_extracted_pixels() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 11, + y: 21, + w: 1, + h: 1, + fill: true, + color: RED, + thick: 1.0, + }); + let snapshot = CanvasRegionExportSnapshot { + source: solid_source(8, 8, 0xFF20_3040), + selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), + frame, + text_halo_enabled: true, + spotlight: SpotlightPassSnapshot::default(), + }; + let pixels = render_canvas_region_pixels(snapshot.clone()).expect("pixels"); + let rendered = render_canvas_region_png(snapshot).expect("png"); + assert_eq!( + (pixels.width(), pixels.height()), + (rendered.width, rendered.height) + ); + for y in 0..pixels.height() as i32 { + for x in 0..pixels.width() as i32 { + assert_eq!( + pixel_at(&pixels, x as u32, y as u32), + decoded_pixel(&rendered, x, y), + "pixel ({x}, {y})" + ); + } + } + } + + #[test] + fn a_drawing_crossing_a_removed_band_is_flattened_then_joined() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 10, + y: 20, + w: 8, + h: 1, + fill: true, + color: RED, + thick: 1.0, + }); + let snapshot = CanvasRegionExportSnapshot { + source: solid_source(8, 8, 0xFF20_3040), + selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), + frame, + text_halo_enabled: true, + spotlight: SpotlightPassSnapshot::default(), + }; + let base = render_canvas_region_pixels(snapshot).expect("flattened"); + let cut = crate::capture::CutBand::new(crate::capture::CutAxis::Columns, 3, 5).unwrap(); + let composed = crate::capture::apply_band_cuts(&base, &[cut]).expect("cut"); + assert_eq!((composed.width(), composed.height()), (6, 8)); + assert_eq!(pixel_at(&composed, 2, 0), 0xFFFF_0000); + assert_eq!(pixel_at(&composed, 3, 0), 0xFFFF_0000); + assert_eq!(pixel_at(&base, 2, 0), 0xFFFF_0000); + assert_eq!(pixel_at(&base, 3, 0), 0xFFFF_0000); + assert_eq!(pixel_at(&base, 5, 0), 0xFFFF_0000); + } + + #[test] + fn packed_extraction_copies_non_tight_cairo_rows() { + let width = 3i32; + let height = 2i32; + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + ctx.set_source_rgba(1.0, 0.0, 0.0, 1.0); + ctx.paint().expect("paint"); + } + let pixels = packed_argb32_from_surface(&mut surface, "stride-test").expect("packed"); + assert_eq!(pixels.stride(), width * 4); + assert_eq!(pixels.data().len(), (width * height * 4) as usize); + assert_eq!(pixel_at(&pixels, 0, 0), 0xFFFF_0000); + assert_eq!(pixel_at(&pixels, 2, 1), 0xFFFF_0000); + } } diff --git a/src/capture/band_cut.rs b/src/capture/band_cut.rs new file mode 100644 index 00000000..2f247f06 --- /dev/null +++ b/src/capture/band_cut.rs @@ -0,0 +1,661 @@ +//! Sequential row/column band cuts on tightly packed ARGB32 rasters. +//! +//! Cuts are expressed in the coordinate system of the raster produced by every +//! preceding cut. The engine is backend-neutral: it does not know about Wayland, +//! Cairo, source tokens, or capture destinations. + +use crate::screen_pixels::PackedArgb32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CutAxis { + Columns, + Rows, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CutBand { + axis: CutAxis, + start: u32, + end: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BandCutError { + EmptyRange, + OutOfBounds, + EmptyOutput, + SizeOverflow, + InvalidPixels, +} + +impl std::fmt::Display for BandCutError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyRange => formatter.write_str("cut range is empty"), + Self::OutOfBounds => formatter.write_str("cut range leaves the current output"), + Self::EmptyOutput => formatter.write_str("cut would remove the entire remaining axis"), + Self::SizeOverflow => formatter.write_str("cut output size overflows"), + Self::InvalidPixels => formatter.write_str("cut pixels are invalid"), + } + } +} + +impl CutBand { + pub(crate) fn new(axis: CutAxis, start: u32, end: u32) -> Result { + if start >= end { + return Err(BandCutError::EmptyRange); + } + Ok(Self { axis, start, end }) + } + + /// Normalize a pair of pixel edges so backward drags match forward drags. + pub(crate) fn from_unordered_edges( + axis: CutAxis, + first: u32, + second: u32, + ) -> Result { + Self::new(axis, first.min(second), first.max(second)) + } + + pub(crate) const fn axis(self) -> CutAxis { + self.axis + } + + pub(crate) const fn start(self) -> u32 { + self.start + } + + pub(crate) const fn end(self) -> u32 { + self.end + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AxisSpan { + start: u32, + end: u32, +} + +impl AxisSpan { + fn len(self) -> Result { + self.end + .checked_sub(self.start) + .ok_or(BandCutError::SizeOverflow) + } +} + +fn initial_spans(extent: u32) -> Vec { + if extent == 0 { + Vec::new() + } else { + vec![AxisSpan { + start: 0, + end: extent, + }] + } +} + +fn axis_extent(spans: &[AxisSpan]) -> Result { + spans.iter().try_fold(0u32, |acc, span| { + acc.checked_add(span.len()?) + .ok_or(BandCutError::SizeOverflow) + }) +} + +fn tight_row(width: u32) -> Result<(i32, usize), BandCutError> { + let row_bytes_u32 = width.checked_mul(4).ok_or(BandCutError::SizeOverflow)?; + let stride = i32::try_from(row_bytes_u32).map_err(|_| BandCutError::SizeOverflow)?; + let row_bytes = usize::try_from(row_bytes_u32).map_err(|_| BandCutError::SizeOverflow)?; + Ok((stride, row_bytes)) +} + +fn checked_buffer_len(row_bytes: usize, height: u32) -> Result { + usize::try_from(height) + .ok() + .and_then(|height| row_bytes.checked_mul(height)) + .ok_or(BandCutError::SizeOverflow) +} + +fn checked_row_offset(row: u32, stride: usize) -> Result { + usize::try_from(row) + .ok() + .and_then(|row| row.checked_mul(stride)) + .ok_or(BandCutError::SizeOverflow) +} + +fn checked_pixel_bytes(offset: u32) -> Result { + usize::try_from(offset) + .ok() + .and_then(|offset| offset.checked_mul(4)) + .ok_or(BandCutError::SizeOverflow) +} + +fn remove_logical_range( + spans: &mut Vec, + start: u32, + end: u32, +) -> Result<(), BandCutError> { + if start >= end { + return Err(BandCutError::EmptyRange); + } + let extent = axis_extent(spans)?; + if end > extent { + return Err(BandCutError::OutOfBounds); + } + + let mut kept = Vec::new(); + let mut logical = 0u32; + for span in spans.iter().copied() { + let span_len = span.len()?; + let logical_end = logical + .checked_add(span_len) + .ok_or(BandCutError::SizeOverflow)?; + if start > logical { + let keep_end = start.min(logical_end); + if keep_end > logical { + let keep_len = keep_end - logical; + kept.push(AxisSpan { + start: span.start, + end: span + .start + .checked_add(keep_len) + .ok_or(BandCutError::SizeOverflow)?, + }); + } + } + if end < logical_end { + let keep_start = end.max(logical); + if keep_start < logical_end { + let skip = keep_start - logical; + kept.push(AxisSpan { + start: span + .start + .checked_add(skip) + .ok_or(BandCutError::SizeOverflow)?, + end: span.end, + }); + } + } + logical = logical_end; + } + if axis_extent(&kept)? == 0 { + return Err(BandCutError::EmptyOutput); + } + *spans = kept; + Ok(()) +} + +fn retained_axes( + source: (u32, u32), + cuts: &[CutBand], +) -> Result<(Vec, Vec), BandCutError> { + let mut columns = initial_spans(source.0); + let mut rows = initial_spans(source.1); + for cut in cuts { + match cut.axis { + CutAxis::Columns => remove_logical_range(&mut columns, cut.start, cut.end)?, + CutAxis::Rows => remove_logical_range(&mut rows, cut.start, cut.end)?, + } + } + Ok((columns, rows)) +} + +/// Replay `cuts` against `source` and return the remaining native size. +pub(crate) fn output_size( + source: (u32, u32), + cuts: &[CutBand], +) -> Result<(u32, u32), BandCutError> { + let (columns, rows) = retained_axes(source, cuts)?; + let width = axis_extent(&columns)?; + let height = axis_extent(&rows)?; + if width == 0 || height == 0 { + return Err(BandCutError::EmptyOutput); + } + Ok((width, height)) +} + +fn copy_retained( + source: &PackedArgb32, + columns: &[AxisSpan], + rows: &[AxisSpan], +) -> Result { + let output_width = axis_extent(columns)?; + let output_height = axis_extent(rows)?; + if output_width == 0 || output_height == 0 { + return Err(BandCutError::EmptyOutput); + } + let (stride, row_bytes) = tight_row(output_width)?; + let length = checked_buffer_len(row_bytes, output_height)?; + let source_stride = usize::try_from(source.stride()).map_err(|_| BandCutError::SizeOverflow)?; + let source_data = source.data(); + let mut output = vec![0u8; length]; + let mut dest_row = 0usize; + for row_span in rows { + let mut source_y = row_span.start; + while source_y < row_span.end { + let source_row = checked_row_offset(source_y, source_stride)?; + let dest_row_offset = dest_row + .checked_mul(row_bytes) + .ok_or(BandCutError::SizeOverflow)?; + let mut dest_bytes = 0usize; + for column_span in columns { + let span_bytes = checked_pixel_bytes(column_span.len()?)?; + let source_start = source_row + .checked_add(checked_pixel_bytes(column_span.start)?) + .ok_or(BandCutError::SizeOverflow)?; + let source_end = source_start + .checked_add(span_bytes) + .ok_or(BandCutError::SizeOverflow)?; + let dest_start = dest_row_offset + .checked_add(dest_bytes) + .ok_or(BandCutError::SizeOverflow)?; + let dest_end = dest_start + .checked_add(span_bytes) + .ok_or(BandCutError::SizeOverflow)?; + let source_run = source_data + .get(source_start..source_end) + .ok_or(BandCutError::InvalidPixels)?; + let dest_run = output + .get_mut(dest_start..dest_end) + .ok_or(BandCutError::SizeOverflow)?; + dest_run.copy_from_slice(source_run); + dest_bytes = dest_bytes + .checked_add(span_bytes) + .ok_or(BandCutError::SizeOverflow)?; + } + dest_row = dest_row.checked_add(1).ok_or(BandCutError::SizeOverflow)?; + source_y = source_y.checked_add(1).ok_or(BandCutError::SizeOverflow)?; + } + } + PackedArgb32::new(output_width, output_height, stride, output) + .ok_or(BandCutError::InvalidPixels) +} + +/// Apply ordered band cuts to a tightly packed raster, allocating one output. +pub(crate) fn apply_band_cuts( + source: &PackedArgb32, + cuts: &[CutBand], +) -> Result { + if cuts.is_empty() { + return Ok(source.clone()); + } + let (columns, rows) = retained_axes((source.width(), source.height()), cuts)?; + copy_retained(source, &columns, &rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pixel(bytes: [u8; 4]) -> u32 { + u32::from_ne_bytes(bytes) + } + + fn unique_raster(width: u32, height: u32) -> PackedArgb32 { + let mut data = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + data.extend_from_slice(&[ + x as u8, + y as u8, + (y.wrapping_mul(width).wrapping_add(x)) as u8, + 0x80 | ((x + y) as u8 & 0x3F), + ]); + } + } + PackedArgb32::new(width, height, (width * 4) as i32, data).expect("unique raster") + } + + fn raster_from_ids(width: u32, height: u32, ids: &[u32]) -> PackedArgb32 { + assert_eq!(ids.len(), (width * height) as usize); + let data = ids + .iter() + .flat_map(|id| id.to_ne_bytes()) + .collect::>(); + PackedArgb32::new(width, height, (width * 4) as i32, data).expect("id raster") + } + + fn ids_of(pixels: &PackedArgb32) -> Vec { + pixels + .data() + .chunks_exact(4) + .map(|chunk| u32::from_ne_bytes(chunk.try_into().expect("pixel"))) + .collect() + } + + fn cut(axis: CutAxis, start: u32, end: u32) -> CutBand { + CutBand::new(axis, start, end).expect("valid cut") + } + + fn reference_one(pixels: &PackedArgb32, cut: CutBand) -> Result { + let width = pixels.width(); + let height = pixels.height(); + let source = pixels.data(); + let source_stride = usize::try_from(pixels.stride()).unwrap(); + match cut.axis { + CutAxis::Columns => { + if cut.start >= cut.end { + return Err(BandCutError::EmptyRange); + } + if cut.end > width { + return Err(BandCutError::OutOfBounds); + } + let removed = cut.end - cut.start; + let output_width = width + .checked_sub(removed) + .ok_or(BandCutError::SizeOverflow)?; + if output_width == 0 { + return Err(BandCutError::EmptyOutput); + } + let (stride, row_bytes) = tight_row(output_width)?; + let mut data = Vec::with_capacity(checked_buffer_len(row_bytes, height)?); + for y in 0..height { + let row = checked_row_offset(y, source_stride)?; + for x in 0..width { + if x >= cut.start && x < cut.end { + continue; + } + let start = row + checked_pixel_bytes(x)?; + data.extend_from_slice(&source[start..start + 4]); + } + } + PackedArgb32::new(output_width, height, stride, data) + .ok_or(BandCutError::InvalidPixels) + } + CutAxis::Rows => { + if cut.start >= cut.end { + return Err(BandCutError::EmptyRange); + } + if cut.end > height { + return Err(BandCutError::OutOfBounds); + } + let removed = cut.end - cut.start; + let output_height = height + .checked_sub(removed) + .ok_or(BandCutError::SizeOverflow)?; + if output_height == 0 { + return Err(BandCutError::EmptyOutput); + } + let (stride, row_bytes) = tight_row(width)?; + let mut data = Vec::with_capacity(checked_buffer_len(row_bytes, output_height)?); + for y in 0..height { + if y >= cut.start && y < cut.end { + continue; + } + let row = checked_row_offset(y, source_stride)?; + data.extend_from_slice(&source[row..row + row_bytes]); + } + PackedArgb32::new(width, output_height, stride, data) + .ok_or(BandCutError::InvalidPixels) + } + } + } + + fn reference_apply( + pixels: &PackedArgb32, + cuts: &[CutBand], + ) -> Result { + let mut current = pixels.clone(); + for cut in cuts { + current = reference_one(¤t, *cut)?; + } + Ok(current) + } + + #[test] + fn no_cuts_returns_an_equal_raster() { + let source = unique_raster(4, 3); + let output = apply_band_cuts(&source, &[]).expect("no cuts"); + assert_eq!(output, source); + assert_eq!(output.stride(), 16); + assert_eq!(output_size((4, 3), &[]), Ok((4, 3))); + } + + #[test] + fn removing_a_middle_column_joins_the_neighbours() { + let source = raster_from_ids(5, 1, &[1, 2, 3, 4, 5]); + let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 2, 3)]).unwrap(); + assert_eq!(ids_of(&output), vec![1, 2, 4, 5]); + assert_eq!(output.stride(), 16); + } + + #[test] + fn first_and_last_column_bands_are_removed() { + let source = raster_from_ids(6, 1, &[1, 2, 3, 4, 5, 6]); + let output = apply_band_cuts( + &source, + &[cut(CutAxis::Columns, 0, 1), cut(CutAxis::Columns, 4, 5)], + ) + .unwrap(); + // After removing the first column: 2 3 4 5 6. The second cut uses that + // output, so [4,5) removes 6. + assert_eq!(ids_of(&output), vec![2, 3, 4, 5]); + } + + #[test] + fn removing_a_middle_row_slides_content_up() { + let source = raster_from_ids(2, 4, &[1, 2, 3, 4, 5, 6, 7, 8]); + let output = apply_band_cuts(&source, &[cut(CutAxis::Rows, 1, 2)]).unwrap(); + assert_eq!(ids_of(&output), vec![1, 2, 5, 6, 7, 8]); + assert_eq!((output.width(), output.height()), (2, 3)); + } + + #[test] + fn first_and_last_row_bands_are_removed() { + let source = raster_from_ids(1, 5, &[1, 2, 3, 4, 5]); + let output = apply_band_cuts( + &source, + &[cut(CutAxis::Rows, 0, 1), cut(CutAxis::Rows, 3, 4)], + ) + .unwrap(); + assert_eq!(ids_of(&output), vec![2, 3, 4]); + } + + #[test] + fn backward_edges_normalize_to_the_same_band() { + assert_eq!( + CutBand::from_unordered_edges(CutAxis::Columns, 5, 2).unwrap(), + CutBand::from_unordered_edges(CutAxis::Columns, 2, 5).unwrap() + ); + } + + #[test] + fn sequential_same_axis_cuts_use_current_output_coordinates() { + // A B C D E F G H, cut [2,4), then [3,5) → A B E H + let labels = [10, 20, 30, 40, 50, 60, 70, 80]; + let source = raster_from_ids(8, 1, &labels); + let output = apply_band_cuts( + &source, + &[cut(CutAxis::Columns, 2, 4), cut(CutAxis::Columns, 3, 5)], + ) + .unwrap(); + assert_eq!(ids_of(&output), vec![10, 20, 50, 80]); + } + + #[test] + fn mixed_row_and_column_cuts_preserve_source_pixels() { + let mut ids = Vec::new(); + for y in 0..4u32 { + for x in 0..4u32 { + ids.push(y * 10 + x); + } + } + let source = raster_from_ids(4, 4, &ids); + let output = apply_band_cuts( + &source, + &[cut(CutAxis::Columns, 1, 2), cut(CutAxis::Rows, 2, 3)], + ) + .unwrap(); + assert_eq!(ids_of(&output), vec![0, 2, 3, 10, 12, 13, 30, 32, 33]); + assert_eq!((output.width(), output.height()), (3, 3)); + } + + #[test] + fn undo_last_then_redo_reproduces_identical_bytes() { + let source = unique_raster(6, 5); + let first = cut(CutAxis::Columns, 1, 3); + let second = cut(CutAxis::Rows, 2, 4); + let both = apply_band_cuts(&source, &[first, second]).unwrap(); + let undone = apply_band_cuts(&source, &[first]).unwrap(); + let redone = apply_band_cuts(&source, &[first, second]).unwrap(); + assert_eq!(redone, both); + assert_ne!(undone, both); + } + + #[test] + fn all_four_native_order_bytes_are_copied_unchanged() { + let bytes = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + let source = PackedArgb32::new(2, 1, 8, bytes.to_vec()).unwrap(); + let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 0, 1)]).unwrap(); + assert_eq!(output.data(), &bytes[4..]); + assert_eq!( + pixel(output.data().try_into().unwrap()), + pixel([0x55, 0x66, 0x77, 0x88]) + ); + } + + #[test] + fn one_pixel_wide_or_high_output_is_valid() { + let source = unique_raster(4, 3); + let column = apply_band_cuts(&source, &[cut(CutAxis::Columns, 1, 4)]).unwrap(); + assert_eq!((column.width(), column.height()), (1, 3)); + let row = apply_band_cuts(&source, &[cut(CutAxis::Rows, 0, 2)]).unwrap(); + assert_eq!((row.width(), row.height()), (4, 1)); + } + + #[test] + fn empty_half_open_range_is_rejected() { + assert_eq!( + CutBand::new(CutAxis::Columns, 3, 3), + Err(BandCutError::EmptyRange) + ); + assert_eq!( + CutBand::from_unordered_edges(CutAxis::Rows, 4, 4), + Err(BandCutError::EmptyRange) + ); + } + + #[test] + fn end_beyond_current_extent_is_rejected() { + let source = unique_raster(3, 2); + assert_eq!( + apply_band_cuts(&source, &[cut(CutAxis::Columns, 1, 4)]), + Err(BandCutError::OutOfBounds) + ); + assert_eq!( + output_size((3, 2), &[cut(CutAxis::Rows, 0, 3)]), + Err(BandCutError::OutOfBounds) + ); + } + + #[test] + fn a_cut_that_clears_an_axis_is_rejected() { + let source = unique_raster(3, 2); + assert_eq!( + apply_band_cuts(&source, &[cut(CutAxis::Columns, 0, 3)]), + Err(BandCutError::EmptyOutput) + ); + assert_eq!( + apply_band_cuts(&source, &[cut(CutAxis::Rows, 0, 2)]), + Err(BandCutError::EmptyOutput) + ); + } + + #[test] + fn a_later_cut_invalid_under_the_earlier_size_is_rejected() { + let source = unique_raster(4, 3); + let cuts = [cut(CutAxis::Columns, 1, 3), cut(CutAxis::Columns, 2, 3)]; + // After the first cut the width is 2, so [2,3) is out of bounds. + assert_eq!( + apply_band_cuts(&source, &cuts), + Err(BandCutError::OutOfBounds) + ); + assert_eq!(output_size((4, 3), &cuts), Err(BandCutError::OutOfBounds)); + } + + #[test] + fn width_times_four_and_allocation_overflow_fail_without_panic() { + assert_eq!(tight_row(u32::MAX), Err(BandCutError::SizeOverflow)); + assert_eq!( + tight_row((i32::MAX as u32 / 4) + 1), + Err(BandCutError::SizeOverflow) + ); + assert_eq!( + checked_buffer_len(usize::MAX, 2), + Err(BandCutError::SizeOverflow) + ); + assert_eq!( + checked_row_offset(2, usize::MAX), + Err(BandCutError::SizeOverflow) + ); + // A u32 pixel index times four always fits in 64-bit usize; the + // helper must still refuse to panic if a later 32-bit target is used. + if usize::BITS <= 32 { + assert_eq!( + checked_pixel_bytes(u32::MAX / 2), + Err(BandCutError::SizeOverflow) + ); + } else { + assert_eq!(checked_pixel_bytes(u32::MAX / 2), Ok(8_589_934_588)); + } + } + + #[test] + fn malformed_packed_pixels_cannot_be_constructed() { + assert_eq!(PackedArgb32::new(2, 2, 12, vec![0; 24]), None); + assert_eq!(PackedArgb32::new(2, 2, 8, vec![0; 15]), None); + assert_eq!(PackedArgb32::new(u32::MAX, 1, -4, Vec::new()), None); + } + + #[test] + fn output_stride_equals_width_times_four() { + let source = unique_raster(7, 5); + let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 2, 4)]).unwrap(); + assert_eq!(output.stride(), (output.width() * 4) as i32); + assert_eq!( + output.data().len(), + output.stride() as usize * output.height() as usize + ); + } + + #[test] + fn retained_interval_engine_matches_sequential_reference() { + for width in 1..=6 { + for height in 1..=6 { + let source = unique_raster(width, height); + assert_eq!( + apply_band_cuts(&source, &[]).unwrap(), + reference_apply(&source, &[]).unwrap() + ); + for axis in [CutAxis::Columns, CutAxis::Rows] { + let extent = if axis == CutAxis::Columns { + width + } else { + height + }; + for start in 0..extent { + for end in (start + 1)..=extent { + let Ok(band) = CutBand::new(axis, start, end) else { + continue; + }; + let optimized = apply_band_cuts(&source, &[band]); + let reference = reference_apply(&source, &[band]); + assert_eq!(optimized, reference, "{width}x{height} {band:?}"); + } + } + } + if width >= 4 && height >= 3 { + let cuts = [ + cut(CutAxis::Columns, 1, 2), + cut(CutAxis::Rows, 0, 1), + cut(CutAxis::Columns, 1, 2), + ]; + assert_eq!( + apply_band_cuts(&source, &cuts), + reference_apply(&source, &cuts) + ); + } + } + } + } +} diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 7f8497eb..1a3ee332 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -7,6 +7,7 @@ //! - Clipboard integration //! - File saving with configurable formats +pub(crate) mod band_cut; pub mod clipboard; pub mod file; pub(crate) mod png; @@ -23,6 +24,7 @@ mod pipeline; #[cfg(test)] mod tests; +pub(crate) use band_cut::{BandCutError, CutAxis, CutBand, apply_band_cuts, output_size}; pub use manager::{CaptureManager, CapturePoll, CaptureRequestId, CaptureSubmitError}; #[allow(unused_imports)] pub(crate) use pipeline::CaptureRequest; diff --git a/src/screen_pixels.rs b/src/screen_pixels.rs index af8d99ee..a28dea5d 100644 --- a/src/screen_pixels.rs +++ b/src/screen_pixels.rs @@ -293,6 +293,38 @@ impl PackedArgb32 { } } +impl ScreenImage { + /// Copy `rect` into a tightly packed ARGB snapshot. The source may carry + /// row padding; the copy never does. + pub(crate) fn copy_rect(&self, rect: ImagePixelRect) -> Option { + if self.width == 0 || self.height == 0 { + return None; + } + if rect.x().checked_add(rect.width())? > self.width + || rect.y().checked_add(rect.height())? > self.height + { + return None; + } + let source_stride = usize::try_from(self.stride).ok()?; + let row_bytes = usize::try_from(rect.width()).ok()?.checked_mul(4)?; + let total_bytes = usize::try_from(rect.height()) + .ok()? + .checked_mul(row_bytes)?; + let stride = i32::try_from(row_bytes).ok()?; + let start_column = usize::try_from(rect.x()).ok()?.checked_mul(4)?; + let mut data = Vec::with_capacity(total_bytes); + for row in 0..rect.height() { + let start = usize::try_from(rect.y().saturating_add(row)) + .ok() + .and_then(|row| row.checked_mul(source_stride)) + .and_then(|row| row.checked_add(start_column))?; + let end = start.checked_add(row_bytes)?; + data.extend_from_slice(self.data.get(start..end)?); + } + PackedArgb32::new(rect.width(), rect.height(), stride, data) + } +} + impl std::fmt::Debug for PackedArgb32 { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter diff --git a/src/ui.rs b/src/ui.rs index f0152415..0bfd7e4a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -59,11 +59,13 @@ pub(crate) use primitives::ellipsize_to_fit; pub(crate) use primitives::{checkerboard_behind, draw_pill}; 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_action_bar::{ + RegionAction, RegionActionAvailability, RegionActionBar, RegionCutStatus, +}; pub(crate) use region_capture_picker::{ - OCR_LEGEND_TEXT, RegionCaptureLoupeVisual, RegionCapturePickerVisual, - RegionCaptureWindowVisual, capture_size_text, measure_picker_damage, - render_region_capture_picker, render_region_legend, + OCR_LEGEND_TEXT, RegionCaptureCutVisual, RegionCaptureLoupeVisual, RegionCapturePickerVisual, + RegionCaptureWindowVisual, RegionCutDragVisual, RegionCutPreviewVisual, capture_size_text, + measure_picker_damage, render_region_capture_picker, render_region_legend, }; pub(crate) use region_resize_handles::RegionResizeHandles; pub(crate) use spotlight_control::render_spotlight_magnification_control; diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs index aafbbaa8..5ef16a28 100644 --- a/src/ui/region_action_bar.rs +++ b/src/ui/region_action_bar.rs @@ -12,8 +12,17 @@ const ITEM_GAP: f64 = 6.0; /// divider is centred in it. const ROW_GAP: f64 = 8.0; const ACTION_ROW_HEIGHT: f64 = 38.0; +const EDIT_ROW_HEIGHT: f64 = 28.0; const TOGGLE_ROW_HEIGHT: f64 = 26.0; -const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + ACTION_ROW_HEIGHT + ROW_GAP + TOGGLE_ROW_HEIGHT; +const STATUS_ROW_HEIGHT: f64 = 16.0; +const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + + ACTION_ROW_HEIGHT + + ROW_GAP + + EDIT_ROW_HEIGHT + + ROW_GAP + + TOGGLE_ROW_HEIGHT + + ROW_GAP + + STATUS_ROW_HEIGHT; /// Resting width of one action control. The bar sizes itself from this instead /// of stretching controls across an arbitrary fixed width. const ACTION_ITEM_WIDTH: f64 = 74.0; @@ -58,6 +67,10 @@ pub(crate) enum RegionAction { Save, Both, Board, + CutBand, + UndoCut, + RedoCut, + ResetCuts, ToggleIncludeDrawings, } @@ -68,6 +81,10 @@ impl RegionAction { Self::Save => "Save", Self::Both => "Both", Self::Board => "Board", + Self::CutBand => "Cut", + Self::UndoCut => "Undo", + Self::RedoCut => "Redo", + Self::ResetCuts => "Reset", Self::ToggleIncludeDrawings => "Include drawings in exports", } } @@ -78,16 +95,101 @@ impl RegionAction { Self::Save => "Ctrl+S", Self::Both => "Enter", Self::Board => "B", + Self::CutBand => "X", + Self::UndoCut => "Ctrl+Z", + Self::RedoCut => "Ctrl+Y", + Self::ResetCuts => "", Self::ToggleIncludeDrawings => "D", } } + /// Destinations that leave Review. Edit controls stay in the picker. + pub(crate) const fn is_terminal(self) -> bool { + matches!(self, Self::Copy | Self::Save | Self::Both | Self::Board) + } + /// The accented default action: the one `Enter` submits. const fn is_primary(self) -> bool { matches!(self, Self::Both) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegionActionAvailability { + pub terminal: bool, + pub cut: bool, + pub undo: bool, + pub redo: bool, + pub reset: bool, +} + +impl RegionActionAvailability { + /// Resting Review bar: terminals and Cut enabled, history empty. + pub(crate) const DEFAULT: Self = Self { + terminal: true, + cut: true, + undo: false, + redo: false, + reset: false, + }; + + pub(crate) const fn allows(self, action: RegionAction) -> bool { + match action { + RegionAction::Copy | RegionAction::Save | RegionAction::Both | RegionAction::Board => { + self.terminal + } + RegionAction::CutBand => self.cut, + RegionAction::UndoCut => self.undo, + RegionAction::RedoCut => self.redo, + RegionAction::ResetCuts => self.reset, + RegionAction::ToggleIncludeDrawings => true, + } + } +} + +impl Default for RegionActionAvailability { + fn default() -> Self { + Self::DEFAULT + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegionCutStatus { + Updating, + Failed, +} + +impl RegionCutStatus { + const fn message(self) -> &'static str { + match self { + Self::Updating => "Updating cut preview…", + Self::Failed => "Cut preview failed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegionActionBarVisual { + pub hovered: Option, + pub include_drawings: bool, + pub availability: RegionActionAvailability, + pub cut_armed: bool, + pub status: Option, +} + +#[cfg(test)] +impl RegionActionBarVisual { + pub(crate) const fn simple(hovered: Option, include_drawings: bool) -> Self { + Self { + hovered, + include_drawings, + availability: RegionActionAvailability::DEFAULT, + cut_armed: false, + status: None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct RegionActionRect { pub x: f64, @@ -124,6 +226,7 @@ struct RegionActionItem { pub(crate) struct RegionActionBar { bounds: RegionActionRect, items: [RegionActionItem; 4], + edit: [RegionActionItem; 4], toggle: RegionActionItem, } @@ -152,36 +255,60 @@ impl RegionActionBar { (surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN), ); let bounds = RegionActionRect::new(x, y, width, height); - let item_height = ACTION_ROW_HEIGHT.min((height - BAR_PADDING * 2.0).max(0.0)); + let width_scale = if BAR_WIDTH > 0.0 { + (width / BAR_WIDTH).clamp(0.0, 1.0) + } else { + 0.0 + }; + let height_scale = if BAR_HEIGHT > 0.0 { + (height / BAR_HEIGHT).clamp(0.0, 1.0) + } else { + 0.0 + }; + let pad_x = BAR_PADDING * width_scale; + let pad_y = BAR_PADDING * height_scale; + let item_gap = ITEM_GAP * width_scale; + let row_gap = ROW_GAP * height_scale; + let action_height = ACTION_ROW_HEIGHT * height_scale; + let edit_height = EDIT_ROW_HEIGHT * height_scale; + let toggle_height = TOGGLE_ROW_HEIGHT * height_scale; let item_count = 4.0; let item_width = - ((width - BAR_PADDING * 2.0 - ITEM_GAP * (item_count - 1.0)) / item_count).max(0.0); - let item = |index: usize, action| RegionActionItem { + ((width - pad_x * 2.0 - item_gap * (item_count - 1.0)) / item_count).max(0.0); + let row_item = |index: usize, action, row_y, row_height| RegionActionItem { action, bounds: RegionActionRect::new( - x + BAR_PADDING + index as f64 * (item_width + ITEM_GAP), - y + BAR_PADDING, + x + pad_x + index as f64 * (item_width + item_gap), + row_y, item_width, - item_height, + row_height, ), }; - let toggle_y = y + BAR_PADDING + item_height + ROW_GAP; + let action_y = y + pad_y; + let edit_y = action_y + action_height + row_gap; + let toggle_y = edit_y + edit_height + row_gap; let toggle = RegionActionItem { action: RegionAction::ToggleIncludeDrawings, bounds: RegionActionRect::new( - x + BAR_PADDING, + x + pad_x, toggle_y, - (width - BAR_PADDING * 2.0).max(0.0), - (y + height - BAR_PADDING - toggle_y).max(0.0), + (width - pad_x * 2.0).max(0.0), + toggle_height, ), }; Self { bounds, items: [ - item(0, RegionAction::Copy), - item(1, RegionAction::Save), - item(2, RegionAction::Both), - item(3, RegionAction::Board), + row_item(0, RegionAction::Copy, action_y, action_height), + row_item(1, RegionAction::Save, action_y, action_height), + row_item(2, RegionAction::Both, action_y, action_height), + row_item(3, RegionAction::Board, action_y, action_height), + ], + edit: [ + row_item(0, RegionAction::CutBand, edit_y, edit_height), + row_item(1, RegionAction::UndoCut, edit_y, edit_height), + row_item(2, RegionAction::RedoCut, edit_y, edit_height), + row_item(3, RegionAction::ResetCuts, edit_y, edit_height), ], toggle, } @@ -196,6 +323,7 @@ impl RegionActionBar { pub(crate) fn hit(self, point: (f64, f64)) -> Option { self.items .iter() + .chain(self.edit.iter()) .find(|item| item.bounds.contains(point)) .map(|item| item.action) .or_else(|| { @@ -206,25 +334,73 @@ impl RegionActionBar { }) } + pub(crate) fn enabled_hit( + self, + point: (f64, f64), + availability: RegionActionAvailability, + ) -> Option { + self.hit(point) + .filter(|&action| availability.allows(action)) + } + pub(crate) fn contains(self, point: (f64, f64)) -> bool { self.bounds.contains(point) } + + fn status_bounds(self) -> Option { + let toggle = self.toggle.bounds; + if toggle.width <= 0.0 { + return None; + } + let pad_y = (self.items[0].bounds.y - self.bounds.y).max(0.0); + let row_gap = + (self.edit[0].bounds.y - self.items[0].bounds.y - self.items[0].bounds.height).max(0.0); + let y = toggle.y + toggle.height + row_gap; + let height = (self.bounds.y + self.bounds.height - pad_y - y).max(0.0); + (height > 0.0).then(|| RegionActionRect::new(toggle.x, y, toggle.width, height)) + } } pub(crate) fn render_region_action_bar( ctx: &cairo::Context, bar: RegionActionBar, - hovered: Option, - include_drawings: bool, + visual: RegionActionBarVisual, ) { let _ = ctx.save(); draw_bar_frame(ctx, bar.bounds); for item in bar.items { - draw_action(ctx, item, hovered == Some(item.action)); + draw_action( + ctx, + item, + visual.hovered == Some(item.action), + visual.availability.allows(item.action), + false, + ); } - draw_row_divider(ctx, bar); - draw_toggle(ctx, bar.toggle, hovered, include_drawings); + draw_row_divider( + ctx, + bar.items[0].bounds, + bar.edit[0].bounds.y, + bar.toggle.bounds.width, + ); + for item in bar.edit { + draw_action( + ctx, + item, + visual.hovered == Some(item.action), + visual.availability.allows(item.action), + visual.cut_armed && item.action == RegionAction::CutBand, + ); + } + draw_row_divider( + ctx, + bar.edit[0].bounds, + bar.toggle.bounds.y, + bar.toggle.bounds.width, + ); + draw_toggle(ctx, bar.toggle, visual.hovered, visual.include_drawings); + draw_status(ctx, bar, visual.status); let _ = ctx.restore(); } @@ -272,32 +448,49 @@ fn draw_bar_frame(ctx: &cairo::Context, bounds: RegionActionRect) { let _ = ctx.stroke(); } -/// Hairline between the action row and the toggle row, inset from the bar's -/// padding so it reads as a grouping rule rather than a border. -fn draw_row_divider(ctx: &cairo::Context, bar: RegionActionBar) { - let toggle = bar.toggle.bounds; - if toggle.width <= 0.0 || toggle.height <= 0.0 { +/// Hairline between stacked rows, inset from the bar's padding so it reads as +/// a grouping rule rather than a border. +fn draw_row_divider(ctx: &cairo::Context, row: RegionActionRect, next_y: f64, width: f64) { + if width <= 0.0 || row.height <= 0.0 { return; } - let y = (toggle.y - ROW_GAP / 2.0).floor() + 0.5; + let gap = (next_y - row.y - row.height).max(0.0); + if gap <= 0.0 { + return; + } + let y = (row.y + row.height + gap / 2.0).floor() + 0.5; theme::set_color(ctx, overlay::DIVIDER_LIGHT); ctx.set_line_width(1.0); - ctx.move_to(toggle.x, y); - ctx.line_to(toggle.x + toggle.width, y); + ctx.move_to(row.x, y); + ctx.line_to(row.x + width, y); let _ = ctx.stroke(); } -fn draw_action(ctx: &cairo::Context, item: RegionActionItem, hovered: bool) { +fn draw_action( + ctx: &cairo::Context, + item: RegionActionItem, + hovered: bool, + enabled: bool, + selected: bool, +) { if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { return; } let primary = item.action.is_primary(); - let (fill, border) = match (primary, hovered) { + let (mut fill, mut border) = match (primary, hovered || selected) { (true, false) => (PRIMARY_BG, PRIMARY_BORDER), (true, true) => (PRIMARY_BG_HOVER, ITEM_BORDER_HOVER), (false, false) => (ITEM_BG, ITEM_BORDER), (false, true) => (ITEM_BG_HOVER, ITEM_BORDER_HOVER), }; + if selected && !primary { + fill = theme::rgba(theme::ACCENT_RGB, 0.35); + border = ITEM_BORDER_HOVER; + } + if !enabled { + fill.3 *= 0.45; + border.3 *= 0.45; + } theme::set_color(ctx, fill); draw_rounded_rect( @@ -322,12 +515,12 @@ fn draw_action(ctx: &cairo::Context, item: RegionActionItem, hovered: bool) { ); let _ = ctx.stroke(); - draw_action_content(ctx, item, primary); + draw_action_content(ctx, item, primary, enabled); } /// Label over keycap, the pair centred as one block so every control's text /// sits on the same optical line regardless of ascenders or descenders. -fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool) { +fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool, enabled: bool) { let _ = ctx.save(); ctx.rectangle( item.bounds.x, @@ -341,35 +534,53 @@ fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bo let label = item.action.label(); let layout = text_layout(ctx, label_style(), label, None); let label_extents = layout.ink_extents(); - let (keycap_width, keycap_height) = keycap_size(ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + let shortcut = item.action.shortcut(); + let (keycap_width, keycap_height) = if shortcut.is_empty() { + (0.0, 0.0) + } else { + keycap_size(ctx, shortcut, KEYCAP_FONT_SIZE) + }; - let stack_height = label_extents.height() + LABEL_KEYCAP_GAP + keycap_height; + let stack_height = if shortcut.is_empty() { + label_extents.height() + } else { + label_extents.height() + LABEL_KEYCAP_GAP + keycap_height + }; let stack_top = item.bounds.y + (item.bounds.height - stack_height) / 2.0; + let text_color = if !enabled { + overlay::TEXT_TERTIARY + } else if primary { + overlay::TEXT_WHITE + } else { + overlay::TEXT_PRIMARY + }; - theme::set_color(ctx, overlay::TEXT_PRIMARY); + theme::set_color(ctx, text_color); layout.show_at_baseline( ctx, center_x - label_extents.width() / 2.0 - label_extents.x_bearing(), stack_top - label_extents.y_bearing(), ); - draw_keycap( - ctx, - center_x - keycap_width / 2.0, - stack_top + label_extents.height() + LABEL_KEYCAP_GAP, - item.action.shortcut(), - KEYCAP_FONT_SIZE, - if primary { - KEYCAP_BG_ON_ACCENT - } else { - KEYCAP_BG - }, - if primary { - overlay::TEXT_WHITE - } else { - overlay::TEXT_HINT - }, - ); + if !shortcut.is_empty() { + draw_keycap( + ctx, + center_x - keycap_width / 2.0, + stack_top + label_extents.height() + LABEL_KEYCAP_GAP, + shortcut, + KEYCAP_FONT_SIZE, + if primary { + KEYCAP_BG_ON_ACCENT + } else { + KEYCAP_BG + }, + if primary { + overlay::TEXT_WHITE + } else { + overlay::TEXT_HINT + }, + ); + } let _ = ctx.restore(); } @@ -488,6 +699,37 @@ fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) let _ = ctx.stroke(); } +fn draw_status(ctx: &cairo::Context, bar: RegionActionBar, status: Option) { + let Some(status) = status else { + return; + }; + let Some(row) = bar.status_bounds() else { + return; + }; + let font_size = (TOGGLE_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0); + if font_size < 1.0 { + return; + } + let _ = ctx.save(); + ctx.rectangle(row.x, row.y, row.width, row.height); + ctx.clip(); + let layout = text_layout(ctx, status_label_style(font_size), status.message(), None); + let extents = layout.ink_extents(); + theme::set_color( + ctx, + match status { + RegionCutStatus::Updating => overlay::TEXT_HINT, + RegionCutStatus::Failed => overlay::TEXT_PRIMARY, + }, + ); + layout.show_at_baseline( + ctx, + row.x + (row.width - extents.width()) / 2.0 - extents.x_bearing(), + row.y + (row.height - extents.height()) / 2.0 - extents.y_bearing(), + ); + let _ = ctx.restore(); +} + fn label_style() -> UiTextStyle<'static> { UiTextStyle { family: "Sans", @@ -498,11 +740,15 @@ fn label_style() -> UiTextStyle<'static> { } fn toggle_label_style() -> UiTextStyle<'static> { + status_label_style(TOGGLE_FONT_SIZE) +} + +fn status_label_style(size: f64) -> UiTextStyle<'static> { UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, weight: cairo::FontWeight::Normal, - size: TOGGLE_FONT_SIZE, + size, } } @@ -510,18 +756,65 @@ fn toggle_label_style() -> UiTextStyle<'static> { mod tests { use super::*; - #[test] - fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { - let centered = RegionActionBar::place( + fn sample_bar() -> RegionActionBar { + RegionActionBar::place( RegionSelection { start: (100.0, 100.0), end: (300.0, 200.0), }, (800, 600), + ) + } + + fn rect_inside(inner: RegionActionRect, outer: RegionActionRect) -> bool { + inner.x + f64::EPSILON >= outer.x + && inner.y + f64::EPSILON >= outer.y + && inner.x + inner.width <= outer.x + outer.width + f64::EPSILON + && inner.y + inner.height <= outer.y + outer.height + f64::EPSILON + } + + fn assert_controls_stay_inside_bar(bar: &RegionActionBar) { + let bounds = bar.bounds(); + for item in bar.items.iter().chain(bar.edit.iter()) { + assert!( + rect_inside(item.bounds, bounds), + "{:?} at ({}, {}) {}x{} leaves bar {bounds:?}", + item.action, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height + ); + } + assert!( + rect_inside(bar.toggle.bounds, bounds), + "toggle leaves bar {bounds:?}" ); + for row in [&bar.items[..], &bar.edit[..]] { + for pair in row.windows(2) { + assert!( + pair[0].bounds.x + pair[0].bounds.width <= pair[1].bounds.x + f64::EPSILON, + "{:?} overlaps {:?}", + pair[0].action, + pair[1].action + ); + } + } + assert!( + bar.items[0].bounds.y + bar.items[0].bounds.height + <= bar.edit[0].bounds.y + f64::EPSILON + ); + assert!( + bar.edit[0].bounds.y + bar.edit[0].bounds.height <= bar.toggle.bounds.y + f64::EPSILON + ); + } + + #[test] + fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { + let centered = sample_bar(); assert_eq!( centered.bounds(), - RegionActionRect::new(35.0, 212.0, 330.0, 88.0) + RegionActionRect::new(35.0, 212.0, 330.0, BAR_HEIGHT) ); let flipped = RegionActionBar::place( @@ -533,56 +826,107 @@ mod tests { ); assert_eq!( flipped.bounds(), - RegionActionRect::new(462.0, 460.0, 330.0, 88.0) + RegionActionRect::new(462.0, 560.0 - SELECTION_GAP - BAR_HEIGHT, 330.0, BAR_HEIGHT) ); } #[test] fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { - let bar = RegionActionBar::place( - RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }, - (800, 600), - ); + let bar = sample_bar(); + let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; + let edit_y = bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0; + let toggle_y = bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0; - assert_eq!(bar.hit((80.0, 239.0)), Some(RegionAction::Copy)); - assert_eq!(bar.hit((160.0, 239.0)), Some(RegionAction::Save)); - assert_eq!(bar.hit((240.0, 239.0)), Some(RegionAction::Both)); - assert_eq!(bar.hit((320.0, 239.0)), Some(RegionAction::Board)); + assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); + assert_eq!(bar.hit((160.0, action_y)), Some(RegionAction::Save)); + assert_eq!(bar.hit((240.0, action_y)), Some(RegionAction::Both)); + assert_eq!(bar.hit((320.0, action_y)), Some(RegionAction::Board)); + assert_eq!(bar.hit((80.0, edit_y)), Some(RegionAction::CutBand)); + assert_eq!(bar.hit((160.0, edit_y)), Some(RegionAction::UndoCut)); + assert_eq!(bar.hit((240.0, edit_y)), Some(RegionAction::RedoCut)); + assert_eq!(bar.hit((320.0, edit_y)), Some(RegionAction::ResetCuts)); assert_eq!( - bar.hit((200.0, 279.0)), + bar.hit((200.0, toggle_y)), Some(RegionAction::ToggleIncludeDrawings) ); - assert_eq!(bar.hit((119.0, 239.0)), None, "inter-item gap"); - assert!(bar.contains((119.0, 239.0)), "bar gaps stay modal-owned"); + assert_eq!(bar.hit((119.0, action_y)), None, "inter-item gap"); + assert!(bar.contains((119.0, action_y)), "bar gaps stay modal-owned"); assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar"); assert!(!bar.contains((20.0, 20.0))); } #[test] - fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() { - let bar = RegionActionBar::place( - RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }, - (800, 600), + fn disabled_controls_still_consume_the_bar_but_return_no_enabled_action() { + let bar = sample_bar(); + let availability = RegionActionAvailability { + terminal: false, + cut: true, + undo: false, + redo: false, + reset: false, + }; + let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; + assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); + assert_eq!(bar.enabled_hit((80.0, action_y), availability), None); + assert!(bar.contains((80.0, action_y))); + assert_eq!( + bar.enabled_hit( + ( + bar.edit[0].bounds.x + bar.edit[0].bounds.width / 2.0, + bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0 + ), + availability + ), + Some(RegionAction::CutBand) ); + } + + #[test] + fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() { + let bar = sample_bar(); let bounds = bar.bounds(); let toggle = bar.toggle.bounds; for item in bar.items { assert!(item.bounds.y >= bounds.y + BAR_PADDING); - assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON); + assert!( + item.bounds.y + item.bounds.height <= bar.edit[0].bounds.y - ROW_GAP + f64::EPSILON + ); assert!(item.bounds.x >= bounds.x + BAR_PADDING); assert!(item.bounds.x + item.bounds.width <= bounds.x + bounds.width - BAR_PADDING); assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); } + for item in bar.edit { + assert!(item.bounds.y >= bar.items[0].bounds.y + bar.items[0].bounds.height); + assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON); + assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); + } assert!(toggle.y + toggle.height <= bounds.y + bounds.height - BAR_PADDING); } + #[test] + fn narrow_and_short_surfaces_keep_controls_inside_the_bar() { + let selection = RegionSelection { + start: (10.0, 10.0), + end: (40.0, 30.0), + }; + for surface in [(200, 80), (80, 40), (40, 600), (800, 36)] { + let bar = RegionActionBar::place(selection, surface); + assert_controls_stay_inside_bar(&bar); + let action = bar.items[0].bounds; + if action.width > 1.0 && action.height > 1.0 { + assert_eq!( + bar.hit(( + action.x + action.width / 2.0, + action.y + action.height / 2.0 + )), + Some(RegionAction::Copy), + "typed hit on {surface:?}" + ); + } + } + } + #[test] fn action_bar_exposes_the_requested_labels_and_shortcuts() { assert_eq!(RegionAction::Copy.label(), "Copy"); @@ -593,11 +937,18 @@ mod tests { assert_eq!(RegionAction::Both.shortcut(), "Enter"); assert_eq!(RegionAction::Board.label(), "Board"); assert_eq!(RegionAction::Board.shortcut(), "B"); + assert_eq!(RegionAction::CutBand.label(), "Cut"); + assert_eq!(RegionAction::CutBand.shortcut(), "X"); + assert_eq!(RegionAction::UndoCut.shortcut(), "Ctrl+Z"); + assert_eq!(RegionAction::RedoCut.shortcut(), "Ctrl+Y"); assert_eq!( RegionAction::ToggleIncludeDrawings.label(), "Include drawings in exports" ); assert_eq!(RegionAction::ToggleIncludeDrawings.shortcut(), "D"); + assert!(RegionAction::Copy.is_terminal()); + assert!(!RegionAction::CutBand.is_terminal()); + assert!(!RegionAction::ToggleIncludeDrawings.is_terminal()); } #[test] @@ -607,6 +958,10 @@ mod tests { RegionAction::Copy, RegionAction::Save, RegionAction::Board, + RegionAction::CutBand, + RegionAction::UndoCut, + RegionAction::RedoCut, + RegionAction::ResetCuts, RegionAction::ToggleIncludeDrawings, ] { assert!(!action.is_primary(), "{action:?} must stay neutral"); @@ -615,52 +970,45 @@ mod tests { #[test] fn rendering_paints_the_bar_and_each_control() { - let bar = RegionActionBar::place( - RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }, - (800, 600), - ); + let bar = sample_bar(); let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar(&ctx, bar, Some(RegionAction::Both), true); + render_region_action_bar( + &ctx, + bar, + RegionActionBarVisual::simple(Some(RegionAction::Both), true), + ); 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]; + let action_y = (bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0) as usize; + let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; - assert!(alpha(40, 250) > 0, "bar surface"); + assert!(alpha(40, action_y) > 0, "bar surface"); for x in [80, 160, 240, 320] { - assert!(alpha(x, 239) > 0, "control at x={x}"); + assert!(alpha(x, action_y) > 0, "control at x={x}"); } - assert!(alpha(56, 279) > 0, "checked drawings checkbox"); + assert!(alpha(56, toggle_y) > 0, "checked drawings checkbox"); assert_eq!(alpha(20, 20), 0, "outside remains untouched"); } #[test] fn the_drawings_checkbox_carries_the_state_instead_of_a_full_width_slab() { - let bar = RegionActionBar::place( - RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }, - (800, 600), - ); + let bar = sample_bar(); + let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; let row_alpha = |checked: bool| { let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar(&ctx, bar, None, checked); + render_region_action_bar(&ctx, bar, RegionActionBarVisual::simple(None, checked)); drop(ctx); surface.flush(); let stride = surface.stride() as usize; let data = surface.data().unwrap(); - // Far right of the toggle row, clear of the checkbox, the label and - // the keycap chip: unfilled in both states. ( - u32::from(data[279 * stride + 300 * 4 + 3]), - u32::from(data[279 * stride + 56 * 4 + 3]), + u32::from(data[toggle_y * stride + 300 * 4 + 3]), + u32::from(data[toggle_y * stride + 56 * 4 + 3]), ) }; @@ -669,4 +1017,104 @@ mod tests { assert_eq!(off_row, on_row, "the row background must not change"); assert!(on_box > 0 && off_box > 0, "the box is drawn either way"); } + + #[test] + fn updating_and_failed_preview_states_paint_status_text() { + let bar = sample_bar(); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &ctx, + bar, + RegionActionBarVisual { + hovered: None, + include_drawings: false, + availability: RegionActionAvailability { + terminal: false, + cut: true, + undo: true, + redo: false, + reset: true, + }, + cut_armed: true, + status: Some(RegionCutStatus::Updating), + }, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let status = bar.status_bounds().unwrap(); + let status_y = (status.y + status.height / 2.0) as usize; + let alpha = data[status_y * stride + 200 * 4 + 3]; + assert!(alpha > 0, "status caption is visible"); + } + + fn paint_bar( + width: i32, + height: i32, + bar: RegionActionBar, + status: Option, + ) -> (usize, Vec) { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &ctx, + bar, + RegionActionBarVisual { + hovered: None, + include_drawings: false, + availability: RegionActionAvailability { + terminal: false, + cut: true, + undo: true, + redo: false, + reset: true, + }, + cut_armed: false, + status, + }, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let pixels = surface.data().unwrap().to_vec(); + (stride, pixels) + } + + #[test] + fn short_surface_status_paint_stays_inside_its_row() { + let selection = RegionSelection { + start: (10.0, 10.0), + end: (40.0, 30.0), + }; + for surface in [(800, 36), (800, 100), (200, 80)] { + let bar = RegionActionBar::place(selection, surface); + let width = i32::try_from(surface.0).unwrap(); + let height = i32::try_from(surface.1).unwrap(); + let (stride, without) = paint_bar(width, height, bar, None); + let (_, with_status) = paint_bar(width, height, bar, Some(RegionCutStatus::Failed)); + let row = bar.status_bounds(); + for y in 0..surface.1 as usize { + for x in 0..surface.0 as usize { + let offset = y * stride + x * 4; + if without[offset..offset + 4] == with_status[offset..offset + 4] { + continue; + } + let Some(row) = row else { + panic!("status painted with no status row on {surface:?}"); + }; + assert!( + row.contains((x as f64 + 0.5, y as f64 + 0.5)), + "status paint at ({x}, {y}) left the {row:?} row on {surface:?}" + ); + assert!( + bar.bounds.contains((x as f64 + 0.5, y as f64 + 0.5)), + "status paint at ({x}, {y}) left the bar on {surface:?}" + ); + } + } + } + } } diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs index 9c57a3f6..75785f77 100644 --- a/src/ui/region_capture_picker.rs +++ b/src/ui/region_capture_picker.rs @@ -1,10 +1,13 @@ +use crate::capture::CutAxis; use crate::input::SelectionHandle; use crate::input::state::RegionSelection; +use crate::screen_pixels::PackedArgb32; use crate::util::Rect; use super::primitives::{draw_rounded_rect, text_extents_for}; use super::region_action_bar::{ - RegionAction, RegionActionBar, RegionActionRect, render_region_action_bar, + RegionAction, RegionActionAvailability, RegionActionBar, RegionActionBarVisual, + RegionActionRect, RegionCutStatus, render_region_action_bar, }; use super::region_resize_handles::{RegionResizeHandles, render_region_resize_handles}; @@ -92,9 +95,31 @@ pub(crate) struct RegionCapturePickerVisual<'a> { pub action_bar: Option, pub hovered_action: Option, pub include_drawings: bool, + pub cut: RegionCaptureCutVisual<'a>, pub window: RegionCaptureWindowVisual<'a>, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCutPreviewVisual<'a> { + pub pixels: &'a PackedArgb32, + pub display: RegionSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCutDragVisual { + pub axis: CutAxis, + pub band: RegionSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) struct RegionCaptureCutVisual<'a> { + pub preview: Option>, + pub drag: Option, + pub availability: RegionActionAvailability, + pub cut_armed: bool, + pub status: Option, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct RegionCaptureLoupeVisual { pub pointer: (f64, f64), @@ -242,6 +267,9 @@ pub(crate) fn render_region_capture_picker( }; let _ = ctx.save(); + if let Some(preview) = visual.cut.preview { + paint_cut_preview(ctx, preview); + } if visual.show_scrim { ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3); ctx.rectangle(0.0, 0.0, width, height); @@ -266,6 +294,9 @@ pub(crate) fn render_region_capture_picker( if let Some(handles) = visual.resize_handles { render_region_resize_handles(ctx, handles, visual.hovered_handle); } + if let Some(drag) = visual.cut.drag { + draw_cut_drag(ctx, drag); + } if !visual.window.active && !visual.review { draw_crosshair(ctx, visual.pointer, (width, height)); } @@ -299,13 +330,96 @@ pub(crate) fn render_region_capture_picker( render_region_action_bar( ctx, action_bar, - visual.hovered_action, - visual.include_drawings, + RegionActionBarVisual { + hovered: visual.hovered_action, + include_drawings: visual.include_drawings, + availability: visual.cut.availability, + cut_armed: visual.cut.cut_armed, + status: visual.cut.status, + }, ); } let _ = ctx.restore(); } +fn paint_cut_preview(ctx: &cairo::Context, preview: RegionCutPreviewVisual<'_>) { + let pixels = preview.pixels; + let Ok(width) = i32::try_from(pixels.width()) else { + return; + }; + let Ok(height) = i32::try_from(pixels.height()) else { + return; + }; + if width <= 0 || height <= 0 { + return; + } + let (x, y, display_width, display_height) = normalized_rect(preview.display); + if display_width <= 0.0 || display_height <= 0.0 { + return; + } + // SAFETY: Cairo borrows `pixels.data` for this surface. The buffer is + // owned by the Review preview and stays alive until the surface is + // dropped at the end of this function. The API wants `*mut u8` even + // though this path only reads pixels; we never write through the + // pointer, and no other alias mutates the buffer while Cairo holds it. + let surface = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + pixels.data().as_ptr() as *mut u8, + cairo::Format::ARgb32, + width, + height, + pixels.stride(), + ) + }; + let Ok(surface) = surface else { + return; + }; + let _ = ctx.save(); + ctx.rectangle(x, y, display_width, display_height); + ctx.clip(); + ctx.translate(x, y); + ctx.scale( + display_width / f64::from(pixels.width()), + display_height / f64::from(pixels.height()), + ); + // Place the surface in the translated/scaled user space, matching the + // frozen-backdrop path: the CTM maps one source pixel onto one displayed + // output pixel, and nearest-neighbor keeps cut seams crisp. + if ctx.set_source_surface(&surface, 0.0, 0.0).is_ok() { + ctx.source().set_filter(cairo::Filter::Nearest); + ctx.source().set_extend(cairo::Extend::None); + let _ = ctx.paint(); + } + let _ = ctx.restore(); +} + +fn draw_cut_drag(ctx: &cairo::Context, drag: RegionCutDragVisual) { + let (x, y, width, height) = normalized_rect(drag.band); + if width <= 0.0 || height <= 0.0 { + return; + } + ctx.set_source_rgba(0.05, 0.08, 0.14, 0.48); + ctx.rectangle(x, y, width, height); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); + ctx.set_line_width(1.0); + match drag.axis { + CutAxis::Columns => { + ctx.move_to(x + 0.5, y); + ctx.line_to(x + 0.5, y + height); + ctx.move_to(x + width - 0.5, y); + ctx.line_to(x + width - 0.5, y + height); + } + CutAxis::Rows => { + ctx.move_to(x, y + 0.5); + ctx.line_to(x + width, y + 0.5); + ctx.move_to(x, y + height - 0.5); + ctx.line_to(x + width, y + height - 0.5); + } + } + let _ = ctx.stroke(); +} + fn normalized_rect(selection: RegionSelection) -> (f64, f64, f64, f64) { let x = selection.start.0.min(selection.end.0); let y = selection.start.1.min(selection.end.1); @@ -696,6 +810,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -740,6 +855,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual { available: true, active: true, @@ -780,6 +896,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual { available: true, active: true, @@ -827,6 +944,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual { available: true, active: true, @@ -873,6 +991,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -940,6 +1059,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -980,6 +1100,7 @@ mod tests { action_bar: Some(bar), hovered_action: Some(crate::ui::RegionAction::Both), include_drawings: true, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -1090,6 +1211,7 @@ mod tests { action_bar: Some(bar), hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -1191,6 +1313,7 @@ mod tests { action_bar: None, hovered_action: None, include_drawings: false, + cut: Default::default(), window: RegionCaptureWindowVisual::disabled(), }, |_x, _y| None, @@ -1245,4 +1368,84 @@ mod tests { assert!(data[98 * stride + 88 * 4 + 3] > 0, "loupe center pixel"); assert_eq!(data[5 * stride + 5 * 4 + 3], 0, "outside untouched"); } + + #[test] + fn accepted_cut_preview_paints_before_the_scrim_hole() { + let pixels = PackedArgb32::new( + 2, + 1, + 8, + [0x33, 0x22, 0x11, 0xFF, 0xCC, 0xBB, 0xAA, 0xFF].to_vec(), + ) + .unwrap(); + let created = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + pixels.data().as_ptr() as *mut u8, + cairo::Format::ARgb32, + 2, + 1, + 8, + ) + }; + assert!( + created.is_ok(), + "preview pixels must be a valid Cairo source: {created:?}" + ); + drop(created); + // Large enough that the 4–20px corner arms cannot cover the samples, + // and far enough from the 1px frame. 2×1 source scales 16× onto this + // 32×16 display: (18, 18) is inside the first source pixel, (34, 18) + // inside the second. + let display = RegionSelection { + start: (10.0, 10.0), + end: (42.0, 26.0), + }; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 60, + 40, + RegionCapturePickerVisual { + selection: Some(display), + pointer: (4.0, 4.0), + measurement: None, + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: RegionCaptureCutVisual { + preview: Some(RegionCutPreviewVisual { + pixels: &pixels, + display, + }), + ..Default::default() + }, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + 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(18, 18) > 0, "preview occupies the displayed output"); + assert!(alpha(2, 2) > 0, "vacated source is dimmed by the scrim"); + assert_eq!( + &data[18 * stride + 18 * 4..18 * stride + 18 * 4 + 4], + &[0x33, 0x22, 0x11, 0xFF], + "first source pixel fills the left half of the displayed output" + ); + assert_eq!( + &data[18 * stride + 34 * 4..18 * stride + 34 * 4 + 4], + &[0xCC, 0xBB, 0xAA, 0xFF], + "second source pixel fills the right half of the displayed output" + ); + } }