diff --git a/packages/rsnap-overlay/src/deferred_text_recognition.rs b/packages/rsnap-overlay/src/deferred_text_recognition.rs index 41406ecc..c652728c 100644 --- a/packages/rsnap-overlay/src/deferred_text_recognition.rs +++ b/packages/rsnap-overlay/src/deferred_text_recognition.rs @@ -11,7 +11,7 @@ use std::{ }; #[cfg(target_os = "macos")] -use image::{Rgba, RgbaImage, imageops}; +use image::{RgbaImage, imageops}; #[cfg(target_os = "macos")] use crate::state::RectPoints; @@ -20,26 +20,14 @@ use crate::{ overlay::output, }; -#[cfg(target_os = "macos")] -const WINDOW_CAPTURE_MATTE_LIGHT_RGBA: Rgba = Rgba([246, 246, 246, 255]); -#[cfg(target_os = "macos")] -const WINDOW_CAPTURE_MATTE_DARK_RGBA: Rgba = Rgba([24, 24, 24, 255]); #[cfg(target_os = "macos")] const PUBLISH_GATE_PENDING_POLL_INTERVAL: Duration = Duration::from_millis(5); -#[cfg(target_os = "macos")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DeferredTextRecognitionWindowMatte { - Light, - Dark, -} - #[cfg(target_os = "macos")] #[derive(Debug)] pub(crate) enum DeferredTextRecognitionImageSource { Prepared { image: RgbaImage }, FrozenCrop { frozen_image: RgbaImage, crop_rect: Option }, - WindowImageWithMatte { window_image: RgbaImage, matte: DeferredTextRecognitionWindowMatte }, } #[cfg(target_os = "macos")] impl DeferredTextRecognitionImageSource { @@ -49,7 +37,6 @@ impl DeferredTextRecognitionImageSource { Self::FrozenCrop { frozen_image, crop_rect } => crop_rect .map(|crop_rect| (crop_rect.width, crop_rect.height)) .unwrap_or_else(|| frozen_image.dimensions()), - Self::WindowImageWithMatte { window_image, .. } => window_image.dimensions(), } } @@ -60,9 +47,6 @@ impl DeferredTextRecognitionImageSource { Self::FrozenCrop { frozen_image, crop_rect } => { export_image_from_frozen_crop(frozen_image, *crop_rect) }, - Self::WindowImageWithMatte { window_image, matte } => { - Some(flatten_window_image_with_matte(window_image, *matte)) - }, } } @@ -72,9 +56,6 @@ impl DeferredTextRecognitionImageSource { Self::FrozenCrop { frozen_image, crop_rect } => { export_image_from_frozen_crop(&frozen_image, crop_rect) }, - Self::WindowImageWithMatte { window_image, matte } => { - Some(flatten_window_image_with_matte(&window_image, matte)) - }, } } } @@ -131,22 +112,6 @@ impl DeferredTextRecognitionRequest { } } - pub(crate) fn window_image_with_matte( - request_id: u64, - requested_at: Instant, - window_image: RgbaImage, - matte: DeferredTextRecognitionWindowMatte, - ) -> Self { - Self { - request_id, - requested_at, - image_source: DeferredTextRecognitionImageSource::WindowImageWithMatte { - window_image, - matte, - }, - } - } - pub(crate) fn image_dimensions(&self) -> (u32, u32) { self.image_source.image_dimensions() } @@ -558,35 +523,6 @@ fn export_image_from_frozen_crop( } } -#[cfg(target_os = "macos")] -fn flatten_window_image_with_matte( - window_image: &RgbaImage, - matte: DeferredTextRecognitionWindowMatte, -) -> RgbaImage { - let matte = match matte { - DeferredTextRecognitionWindowMatte::Light => WINDOW_CAPTURE_MATTE_LIGHT_RGBA, - DeferredTextRecognitionWindowMatte::Dark => WINDOW_CAPTURE_MATTE_DARK_RGBA, - }; - let mut flattened = window_image.clone(); - - for pixel in flattened.pixels_mut() { - let alpha = u16::from(pixel[3]); - let inv_alpha = 255_u16.saturating_sub(alpha); - - for channel in 0..3 { - let src = u16::from(pixel[channel]); - let bg = u16::from(matte[channel]); - let blended = (src.saturating_mul(alpha) + bg.saturating_mul(inv_alpha) + 127) / 255; - - pixel[channel] = blended as u8; - } - - pixel[3] = 255; - } - - flattened -} - #[cfg(target_os = "macos")] fn log_ocr_request_completed( request_id: u64, @@ -627,7 +563,7 @@ mod tests { use crate::deferred_text_recognition::{ self, DeferredTextRecognitionImageSource, DeferredTextRecognitionPublishGate, - DeferredTextRecognitionRequest, DeferredTextRecognitionWindowMatte, + DeferredTextRecognitionRequest, }; use crate::state::RectPoints; @@ -653,21 +589,6 @@ mod tests { assert_eq!(*export.get_pixel(1, 1), Rgba([40, 50, 60, 255])); } - #[test] - fn window_matte_source_flattens_alpha_before_ocr() { - let request = DeferredTextRecognitionRequest { - request_id: 11, - requested_at: Instant::now(), - image_source: DeferredTextRecognitionImageSource::WindowImageWithMatte { - window_image: RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 128])), - matte: DeferredTextRecognitionWindowMatte::Light, - }, - }; - let export = request.export_image().expect("export image"); - - assert_eq!(*export.get_pixel(0, 0), Rgba([123, 123, 123, 255])); - } - #[cfg(target_os = "macos")] #[test] fn publish_gate_only_allows_latest_capture_generation() { diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 12013475..337f859b 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -147,9 +147,9 @@ use self::rendering::{ #[cfg(all(target_os = "macos", test))] use self::session_state::InflightScrollCaptureObservation; use self::session_state::{ - CursorMoveTrace, FrozenSelectionDragCursorMoveTiming, FrozenSelectionDragState, - FrozenToolbarPointerState, FrozenToolbarState, HudDrawConfig, LiveSampleApplyResult, - ScrollCaptureState, SlowOperationLogger, WindowFreezeCaptureTarget, + CursorMoveTrace, FrozenMosaicDragState, FrozenSelectionDragCursorMoveTiming, + FrozenSelectionDragState, FrozenToolbarPointerState, FrozenToolbarState, HudDrawConfig, + LiveSampleApplyResult, ScrollCaptureState, SlowOperationLogger, WindowFreezeCaptureTarget, }; #[cfg(target_os = "macos")] use self::session_state::{ @@ -162,9 +162,7 @@ use self::trace_recording::{ ScrollCaptureTraceFrameRecord, ScrollCaptureTraceRecorder, ScrollCaptureTraceSessionSnapshot, }; #[cfg(target_os = "macos")] -use crate::deferred_text_recognition::{ - DeferredTextRecognitionRequest, DeferredTextRecognitionWindowMatte, -}; +use crate::deferred_text_recognition::DeferredTextRecognitionRequest; #[cfg(target_os = "macos")] use crate::live_frame_stream_macos::{CursorSampleRequest, MacLiveFrameStream}; use crate::scroll_capture::{self, ScrollDirection, ScrollObserveOutcome, ScrollSession}; @@ -335,6 +333,8 @@ const FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS: f32 = 8.0; const FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS: f32 = 12.0; const FROZEN_SELECTION_RESIZE_HANDLE_BORDER_GAP_POINTS: f32 = 0.0; const FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS: f32 = 2.55; +const FROZEN_MOSAIC_BLOCK_SIZE_PX: u32 = 12; +const FROZEN_EDIT_HISTORY_LIMIT: usize = 24; const WINDOW_CAPTURE_MATTE_LIGHT_RGBA: image::Rgba = image::Rgba([246, 246, 246, 255]); const WINDOW_CAPTURE_MATTE_DARK_RGBA: image::Rgba = image::Rgba([24, 24, 24, 255]); const SCROLL_PREVIEW_WINDOW_WIDTH_POINTS: f64 = 260.0; @@ -533,18 +533,33 @@ impl FrozenToolbarTool { const fn requires_final_capture(self) -> bool { match self { - Self::Pointer - | Self::Pen - | Self::Text - | Self::Mosaic - | Self::Undo - | Self::Redo - | Self::AutoCenter => false, + Self::Pointer | Self::Pen | Self::Text | Self::AutoCenter => false, + Self::Mosaic | Self::Undo | Self::Redo => true, Self::Scroll | Self::Copy | Self::Save => true, #[cfg(target_os = "macos")] Self::Ocr => true, } } + + fn is_available(self, toolbar_state: &FrozenToolbarState) -> bool { + match self { + Self::Undo => toolbar_state.undo_available, + Self::Redo => toolbar_state.redo_available, + _ => true, + } + } + + fn unavailable_label(self, toolbar_state: &FrozenToolbarState) -> &'static str { + if self.requires_final_capture() && !toolbar_state.final_capture_ready { + return "Preparing capture..."; + } + + match self { + Self::Undo => "Nothing to undo", + Self::Redo => "Nothing to redo", + _ => "Preparing capture...", + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -824,6 +839,9 @@ pub struct OverlaySession { left_mouse_button_down_monitor: Option, left_mouse_button_down_global: Option, frozen_selection_drag: FrozenSelectionDragState, + frozen_mosaic_drag: FrozenMosaicDragState, + frozen_mosaic_undo_stack: Vec, + frozen_mosaic_redo_stack: Vec, hud_window_visible: bool, toolbar_window_visible: bool, skip_toolbar_focus_on_next_show: bool, @@ -1026,6 +1044,9 @@ impl OverlaySession { toolbar_pointer_local: None, left_mouse_button_down: false, left_mouse_button_down_monitor: None, left_mouse_button_down_global: None, frozen_selection_drag: FrozenSelectionDragState::default(), + frozen_mosaic_drag: FrozenMosaicDragState::default(), + frozen_mosaic_undo_stack: Vec::new(), + frozen_mosaic_redo_stack: Vec::new(), hud_window_visible: false, toolbar_window_visible: false, skip_toolbar_focus_on_next_show: false, toolbar_window_warmup_redraws_remaining: 0, loupe_window_visible: false, @@ -1510,9 +1531,14 @@ impl OverlaySession { self.state.begin_freeze(monitor); self.state.frozen_capture_rect = Some(capture_rect); + self.state.frozen_mosaic_preview_rect = None; self.state.drag_rect = None; self.state.hovered_window_rect = None; self.frozen_selection_drag = FrozenSelectionDragState::default(); + self.frozen_mosaic_drag = FrozenMosaicDragState::default(); + + self.frozen_mosaic_undo_stack.clear(); + self.frozen_mosaic_redo_stack.clear(); tracing::debug!( monitor_id = monitor.id, @@ -1636,7 +1662,7 @@ impl OverlaySession { self.state.drag_rect = Some(MonitorRectPoints { monitor_id: monitor.id, rect }); } - fn frozen_selection_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { + fn frozen_capture_rect_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { if !matches!(self.state.mode, OverlayMode::Frozen) || self.frozen_capture_source != FrozenCaptureSource::DragRegion || self.scroll_capture.active @@ -1655,8 +1681,34 @@ impl OverlaySession { Some((monitor, capture_rect)) } + fn frozen_selection_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { + (self.toolbar_state.selected_tool == FrozenToolbarTool::Pointer) + .then(|| self.frozen_capture_rect_drag_target()) + .flatten() + } + fn frozen_auto_center_available(&self) -> bool { - self.frozen_selection_drag_target().is_some() + self.frozen_capture_rect_drag_target().is_some() + } + + fn frozen_mosaic_drag_target(&self) -> Option<(MonitorRect, RectPoints)> { + if !matches!(self.state.mode, OverlayMode::Frozen) + || self.scroll_capture.active + || self.state.frozen_image.is_none() + || !self.frozen_final_capture_ready() + || self.toolbar_state.selected_tool != FrozenToolbarTool::Mosaic + { + return None; + } + + let monitor = self.state.monitor?; + let capture_rect = self.state.frozen_capture_rect?; + + if capture_rect.is_empty() { + return None; + } + + Some((monitor, capture_rect)) } fn begin_frozen_selection_drag(&mut self, global: GlobalPoint) -> bool { @@ -1687,6 +1739,25 @@ impl OverlaySession { true } + fn begin_frozen_mosaic_drag(&mut self, global: GlobalPoint) -> bool { + let Some((monitor, capture_rect)) = self.frozen_mosaic_drag_target() else { + return false; + }; + let Some((cursor_x, cursor_y)) = monitor.local_u32(global) else { + return false; + }; + + if !capture_rect.contains((cursor_x, cursor_y)) { + return false; + } + + self.frozen_mosaic_drag = + FrozenMosaicDragState { active: true, anchor_x: cursor_x, anchor_y: cursor_y }; + self.state.frozen_mosaic_preview_rect = Some(RectPoints::new(cursor_x, cursor_y, 1, 1)); + + true + } + fn frozen_selection_interaction_kind( capture_rect: RectPoints, cursor_x: u32, @@ -1712,6 +1783,28 @@ impl OverlaySession { } fn frozen_selection_cursor_icon_for_monitor(&self, monitor: MonitorRect) -> CursorIcon { + if let Some((target_monitor, capture_rect)) = self.frozen_mosaic_drag_target() { + if target_monitor != monitor { + return CursorIcon::Default; + } + if self.frozen_mosaic_drag.active { + return CursorIcon::Crosshair; + } + + let Some(cursor) = self.state.cursor else { + return CursorIcon::Default; + }; + let Some((cursor_x, cursor_y)) = monitor.local_u32(cursor) else { + return CursorIcon::Default; + }; + + return if capture_rect.contains((cursor_x, cursor_y)) { + CursorIcon::Crosshair + } else { + CursorIcon::Default + }; + } + let Some((target_monitor, capture_rect)) = self.frozen_selection_drag_target() else { return CursorIcon::Default; }; @@ -1803,6 +1896,11 @@ impl OverlaySession { } } + fn stop_frozen_mosaic_drag(&mut self) { + self.frozen_mosaic_drag = FrozenMosaicDragState::default(); + self.state.frozen_mosaic_preview_rect = None; + } + fn update_frozen_selection_drag_rect(&mut self, global: GlobalPoint) -> bool { if !self.frozen_selection_drag.active { return false; @@ -1848,6 +1946,35 @@ impl OverlaySession { self.apply_frozen_capture_rect_update(monitor, next_rect) } + fn update_frozen_mosaic_drag_rect(&mut self, global: GlobalPoint) -> bool { + if !self.frozen_mosaic_drag.active { + return false; + } + + let Some((monitor, capture_rect)) = self.frozen_mosaic_drag_target() else { + self.stop_frozen_mosaic_drag(); + + return false; + }; + let (cursor_x, cursor_y) = Self::clamped_local_point_in_rect(monitor, capture_rect, global); + let next_rect = Self::rect_from_drag_points( + self.frozen_mosaic_drag.anchor_x, + self.frozen_mosaic_drag.anchor_y, + cursor_x, + cursor_y, + ); + + if self.state.frozen_mosaic_preview_rect == Some(next_rect) { + return false; + } + + self.state.frozen_mosaic_preview_rect = Some(next_rect); + + self.request_redraw_for_monitor(monitor); + + true + } + fn clamped_local_point_in_monitor(monitor: MonitorRect, global: GlobalPoint) -> (u32, u32) { let max_x = i64::from(monitor.width.saturating_sub(1)); let max_y = i64::from(monitor.height.saturating_sub(1)); @@ -1857,6 +1984,32 @@ impl OverlaySession { (local_x, local_y) } + fn clamped_local_point_in_rect( + monitor: MonitorRect, + capture_rect: RectPoints, + global: GlobalPoint, + ) -> (u32, u32) { + let (local_x, local_y) = Self::clamped_local_point_in_monitor(monitor, global); + let max_x = capture_rect.x.saturating_add(capture_rect.width.saturating_sub(1)); + let max_y = capture_rect.y.saturating_add(capture_rect.height.saturating_sub(1)); + + (local_x.clamp(capture_rect.x, max_x), local_y.clamp(capture_rect.y, max_y)) + } + + fn rect_from_drag_points( + anchor_x: u32, + anchor_y: u32, + cursor_x: u32, + cursor_y: u32, + ) -> RectPoints { + let left = anchor_x.min(cursor_x); + let top = anchor_y.min(cursor_y); + let right = anchor_x.max(cursor_x).saturating_add(1); + let bottom = anchor_y.max(cursor_y).saturating_add(1); + + RectPoints::new(left, top, right.saturating_sub(left), bottom.saturating_sub(top)) + } + fn local_point_in_monitor_space(monitor: MonitorRect, global: GlobalPoint) -> (i64, i64) { ( i64::from(global.x) - i64::from(monitor.origin.x), @@ -2033,7 +2186,7 @@ impl OverlaySession { } fn auto_center_frozen_capture_rect(&mut self) -> bool { - let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { + let Some((monitor, capture_rect)) = self.frozen_capture_rect_drag_target() else { return false; }; let Some(capture_image) = self.cropped_frozen_capture_image() else { @@ -2247,16 +2400,10 @@ impl OverlaySession { match self.config.window_capture_alpha_mode { WindowCaptureAlphaMode::Background => {}, WindowCaptureAlphaMode::MatteLight => { - return Some(Self::flatten_window_image_with_matte( - window_image, - WINDOW_CAPTURE_MATTE_LIGHT_RGBA, - )); + return Some(window_image.clone()); }, WindowCaptureAlphaMode::MatteDark => { - return Some(Self::flatten_window_image_with_matte( - window_image, - WINDOW_CAPTURE_MATTE_DARK_RGBA, - )); + return Some(window_image.clone()); }, } } @@ -2312,10 +2459,295 @@ impl OverlaySession { } } + fn intersect_rect_points(left: RectPoints, right: RectPoints) -> Option { + let x = left.x.max(right.x); + let y = left.y.max(right.y); + let right_edge = left.x.saturating_add(left.width).min(right.x.saturating_add(right.width)); + let bottom_edge = + left.y.saturating_add(left.height).min(right.y.saturating_add(right.height)); + + (right_edge > x && bottom_edge > y).then(|| { + RectPoints::new(x, y, right_edge.saturating_sub(x), bottom_edge.saturating_sub(y)) + }) + } + + fn build_frozen_image_patch(image: &RgbaImage, rect: RectPoints) -> Option { + let x = rect.x.min(image.width()); + let y = rect.y.min(image.height()); + let max_width = image.width().saturating_sub(x); + let max_height = image.height().saturating_sub(y); + let width = rect.width.min(max_width); + let height = rect.height.min(max_height); + + if width == 0 || height == 0 { + return None; + } + + let rect = RectPoints::new(x, y, width, height); + let before = imageops::crop_imm(image, x, y, width, height).to_image(); + let after = Self::mosaic_patch(&before); + + Some(FrozenImagePatch { rect, before, after }) + } + + fn mosaic_patch(region: &RgbaImage) -> RgbaImage { + let mut out = region.clone(); + + for block_y in (0..region.height()).step_by(FROZEN_MOSAIC_BLOCK_SIZE_PX as usize) { + for block_x in (0..region.width()).step_by(FROZEN_MOSAIC_BLOCK_SIZE_PX as usize) { + let block_width = FROZEN_MOSAIC_BLOCK_SIZE_PX.min(region.width() - block_x); + let block_height = FROZEN_MOSAIC_BLOCK_SIZE_PX.min(region.height() - block_y); + let mut sum = [0_u64; 4]; + let mut samples = 0_u64; + + for y in block_y..block_y.saturating_add(block_height) { + for x in block_x..block_x.saturating_add(block_width) { + let pixel = region.get_pixel(x, y); + + sum[0] = sum[0].saturating_add(u64::from(pixel[0])); + sum[1] = sum[1].saturating_add(u64::from(pixel[1])); + sum[2] = sum[2].saturating_add(u64::from(pixel[2])); + sum[3] = sum[3].saturating_add(u64::from(pixel[3])); + samples = samples.saturating_add(1); + } + } + + if samples == 0 { + continue; + } + + let fill = image::Rgba([ + (sum[0] / samples) as u8, + (sum[1] / samples) as u8, + (sum[2] / samples) as u8, + (sum[3] / samples) as u8, + ]); + + for y in block_y..block_y.saturating_add(block_height) { + for x in block_x..block_x.saturating_add(block_width) { + out.put_pixel(x, y, fill); + } + } + } + } + + out + } + + fn apply_frozen_image_patch(image: &mut RgbaImage, patch: &FrozenImagePatch, use_after: bool) { + let source = if use_after { &patch.after } else { &patch.before }; + + imageops::replace(image, source, i64::from(patch.rect.x), i64::from(patch.rect.y)); + } + + fn map_rect_into_window_image( + monitor: MonitorRect, + capture_rect_points: RectPoints, + window_image: &RgbaImage, + selection_rect_points: RectPoints, + ) -> Option { + let capture_rect_px = monitor.local_rect_to_pixels(capture_rect_points); + let selection_rect_px = monitor.local_rect_to_pixels(selection_rect_points); + let selection_rect_px = Self::intersect_rect_points(selection_rect_px, capture_rect_px)?; + + if capture_rect_px.is_empty() || window_image.width() == 0 || window_image.height() == 0 { + return None; + } + + let rel_left = selection_rect_px.x.saturating_sub(capture_rect_px.x); + let rel_top = selection_rect_px.y.saturating_sub(capture_rect_px.y); + let rel_right = rel_left.saturating_add(selection_rect_px.width); + let rel_bottom = rel_top.saturating_add(selection_rect_px.height); + let capture_width = u64::from(capture_rect_px.width.max(1)); + let capture_height = u64::from(capture_rect_px.height.max(1)); + let target_width = u64::from(window_image.width()); + let target_height = u64::from(window_image.height()); + let left = (u64::from(rel_left) * target_width) / capture_width; + let top = (u64::from(rel_top) * target_height) / capture_height; + let right = (u64::from(rel_right) * target_width).div_ceil(capture_width); + let bottom = (u64::from(rel_bottom) * target_height).div_ceil(capture_height); + let width = right.saturating_sub(left) as u32; + let height = bottom.saturating_sub(top) as u32; + + (width > 0 && height > 0).then(|| { + RectPoints::new( + left.min(target_width) as u32, + top.min(target_height) as u32, + width, + height, + ) + }) + } + + fn refresh_frozen_cursor_samples(&mut self, monitor: MonitorRect) { + if let Some(cursor) = self.state.cursor { + self.state.rgb = + image_helpers::frozen_rgb(&self.state.frozen_image, Some(monitor), cursor); + self.state.loupe = image_helpers::frozen_loupe_patch( + &self.state.frozen_image, + Some(monitor), + cursor, + self.loupe_patch_width_px, + self.loupe_patch_height_px, + ) + .map(|patch| crate::state::LoupeSample { center: cursor, patch }); + } + } + + fn note_frozen_image_mutated(&mut self, monitor: MonitorRect) { + self.state.frozen_generation = self.state.frozen_generation.wrapping_add(1); + + self.refresh_frozen_cursor_samples(monitor); + self.sync_frozen_toolbar_state(); + self.request_redraw_for_monitor(monitor); + self.request_redraw_hud_window(); + self.request_redraw_toolbar_window(); + + if self.state.alt_held || self.loupe_window_visible { + self.request_redraw_loupe_window(); + } + } + + fn push_frozen_mosaic_edit(&mut self, edit: FrozenMosaicEdit) { + self.frozen_mosaic_undo_stack.push(edit); + + if self.frozen_mosaic_undo_stack.len() > FROZEN_EDIT_HISTORY_LIMIT { + self.frozen_mosaic_undo_stack.remove(0); + } + + self.frozen_mosaic_redo_stack.clear(); + } + + fn apply_frozen_mosaic_edit(&mut self, rect_points: RectPoints) -> bool { + let Some(monitor) = self.state.monitor else { + return false; + }; + + if !self.frozen_final_capture_ready() { + return false; + } + + let preview_rect_px = monitor.local_rect_to_pixels(rect_points); + let Some(preview_patch) = self + .state + .frozen_image + .as_ref() + .and_then(|image| Self::build_frozen_image_patch(image, preview_rect_px)) + else { + return false; + }; + let window_patch = match (self.frozen_window_image.as_ref(), self.state.frozen_capture_rect) + { + (Some(window_image), Some(capture_rect_points)) => Self::map_rect_into_window_image( + monitor, + capture_rect_points, + window_image, + rect_points, + ) + .and_then(|rect| Self::build_frozen_image_patch(window_image, rect)), + _ => None, + }; + + if let Some(image) = self.state.frozen_image.as_mut() { + Self::apply_frozen_image_patch(image, &preview_patch, true); + } + if let (Some(window_image), Some(window_patch)) = + (self.frozen_window_image.as_mut(), window_patch.as_ref()) + { + Self::apply_frozen_image_patch(window_image, window_patch, true); + } + + self.push_frozen_mosaic_edit(FrozenMosaicEdit { preview_patch, window_patch }); + self.note_frozen_image_mutated(monitor); + + true + } + + fn replay_frozen_mosaic_edit(&mut self, edit: &FrozenMosaicEdit, use_after: bool) -> bool { + let Some(monitor) = self.state.monitor else { + return false; + }; + + if let Some(image) = self.state.frozen_image.as_mut() { + Self::apply_frozen_image_patch(image, &edit.preview_patch, use_after); + } + if let (Some(window_image), Some(window_patch)) = + (self.frozen_window_image.as_mut(), edit.window_patch.as_ref()) + { + Self::apply_frozen_image_patch(window_image, window_patch, use_after); + } + + self.note_frozen_image_mutated(monitor); + + true + } + + fn undo_frozen_mosaic_edit(&mut self) -> bool { + if !self.frozen_final_capture_ready() { + return false; + } + + let Some(edit) = self.frozen_mosaic_undo_stack.pop() else { + return false; + }; + let reapplied = self.replay_frozen_mosaic_edit(&edit, false); + + self.frozen_mosaic_redo_stack.push(edit); + self.sync_frozen_toolbar_state(); + + reapplied + } + + fn redo_frozen_mosaic_edit(&mut self) -> bool { + if !self.frozen_final_capture_ready() { + return false; + } + + let Some(edit) = self.frozen_mosaic_redo_stack.pop() else { + return false; + }; + let reapplied = self.replay_frozen_mosaic_edit(&edit, true); + + self.frozen_mosaic_undo_stack.push(edit); + self.sync_frozen_toolbar_state(); + + reapplied + } + + fn commit_frozen_mosaic_drag(&mut self) -> bool { + let preview_rect = self.state.frozen_mosaic_preview_rect; + + self.stop_frozen_mosaic_drag(); + + let Some(preview_rect) = preview_rect else { + return false; + }; + + if preview_rect.width <= 1 && preview_rect.height <= 1 { + return false; + } + + self.apply_frozen_mosaic_edit(preview_rect) + } + fn flatten_window_image_with_matte(image: &RgbaImage, matte: image::Rgba) -> RgbaImage { - let mut out = RgbaImage::from_pixel(image.width(), image.height(), matte); + let mut out = image.clone(); + + for pixel in out.pixels_mut() { + let alpha = u16::from(pixel[3]); + let inv_alpha = 255_u16.saturating_sub(alpha); - imageops::overlay(&mut out, image, 0, 0); + for channel in 0..3 { + let src = u16::from(pixel[channel]); + let bg = u16::from(matte[channel]); + let blended = + (src.saturating_mul(alpha) + bg.saturating_mul(inv_alpha) + 127) / 255; + + pixel[channel] = blended as u8; + } + + pixel[3] = 255; + } out } @@ -2397,17 +2829,19 @@ impl OverlaySession { match self.config.window_capture_alpha_mode { WindowCaptureAlphaMode::Background => {}, WindowCaptureAlphaMode::MatteLight | WindowCaptureAlphaMode::MatteDark => { + let window_capture_image = Self::compose_window_preview_layer( + &window_capture_image, + self.config.window_capture_alpha_mode, + ); + + frozen_preview_image = Self::composite_window_capture_preview( + frozen_preview_image, + &window_capture_image, + monitor, + target.rect, + WindowCaptureAlphaMode::Background, + ); self.frozen_window_image = Some(window_capture_image); - - if let Some(window_capture_image) = self.frozen_window_image.as_ref() { - frozen_preview_image = Self::composite_window_capture_preview( - frozen_preview_image, - window_capture_image, - monitor, - target.rect, - self.config.window_capture_alpha_mode, - ); - } }, } } @@ -2522,6 +2956,7 @@ impl OverlaySession { button: MouseButton, ) { if state == ElementState::Released && button == MouseButton::Left { + self.commit_frozen_mosaic_drag(); self.stop_frozen_selection_drag(); self.sync_overlay_cursor_icons(); } @@ -2673,6 +3108,7 @@ impl OverlaySession { if !toolbar_left_button_down { self.stop_frozen_selection_drag(); + self.stop_frozen_mosaic_drag(); self.toolbar_state.dragging = false; self.toolbar_state.drag_offset = Vec2::ZERO; @@ -3231,9 +3667,15 @@ impl OverlaySession { let frozen_drag_update_started_at = Instant::now(); let frozen_rect_changed = self.update_frozen_selection_drag_rect(global); + self.update_frozen_mosaic_drag_rect(global); + (frozen_rect_changed, Some(frozen_drag_update_started_at.elapsed())) } else { - (self.update_frozen_selection_drag_rect(global), None) + let frozen_rect_changed = self.update_frozen_selection_drag_rect(global); + + self.update_frozen_mosaic_drag_rect(global); + + (frozen_rect_changed, None) }; let sync_cursor_icons_elapsed = Self::measure_duration_if(should_trace_frozen_selection_drag_timing, || { @@ -3558,12 +4000,16 @@ impl OverlaySession { match state { ElementState::Pressed => { let cursor = self.current_device_cursor(); - let _ = self.begin_frozen_selection_drag(cursor); + + if !self.begin_frozen_selection_drag(cursor) { + let _ = self.begin_frozen_mosaic_drag(cursor); + } self.sync_overlay_cursor_icons(); }, ElementState::Released => { self.stop_frozen_selection_drag(); + self.stop_frozen_mosaic_drag(); self.sync_overlay_cursor_icons(); }, } @@ -4317,21 +4763,19 @@ impl OverlaySession { WindowCaptureAlphaMode::Background => {}, WindowCaptureAlphaMode::MatteLight => { if let Some(window_image) = self.frozen_window_image.take() { - return Some(DeferredTextRecognitionRequest::window_image_with_matte( + return Some(DeferredTextRecognitionRequest::prepared( request_id, requested_at, window_image, - DeferredTextRecognitionWindowMatte::Light, )); } }, WindowCaptureAlphaMode::MatteDark => { if let Some(window_image) = self.frozen_window_image.take() { - return Some(DeferredTextRecognitionRequest::window_image_with_matte( + return Some(DeferredTextRecognitionRequest::prepared( request_id, requested_at, window_image, - DeferredTextRecognitionWindowMatte::Dark, )); } }, @@ -4585,6 +5029,8 @@ impl OverlaySession { fn sync_frozen_toolbar_state(&mut self) { self.toolbar_state.auto_center_available = self.frozen_auto_center_available(); + self.toolbar_state.undo_available = !self.frozen_mosaic_undo_stack.is_empty(); + self.toolbar_state.redo_available = !self.frozen_mosaic_redo_stack.is_empty(); self.toolbar_state.scroll_capture_active = self.scroll_capture.active; // Keep drag-region toolbar geometry stable across the authoritative frozen-capture handoff: // show the Scroll slot immediately, but keep it disabled until final_capture_ready flips. @@ -5397,6 +5843,16 @@ impl OverlaySession { OverlayControl::Continue }, + FrozenToolbarTool::Undo => { + self.undo_frozen_mosaic_edit(); + + OverlayControl::Continue + }, + FrozenToolbarTool::Redo => { + self.redo_frozen_mosaic_edit(); + + OverlayControl::Continue + }, FrozenToolbarTool::Copy => { self.begin_png_action(PngAction::Copy); @@ -5565,6 +6021,9 @@ impl OverlaySession { self.toolbar_pointer_local = None; self.stop_frozen_selection_drag(); + self.stop_frozen_mosaic_drag(); + self.frozen_mosaic_undo_stack.clear(); + self.frozen_mosaic_redo_stack.clear(); self.clear_pending_output_actions(); } @@ -5602,6 +6061,19 @@ impl Default for OverlaySession { } } +#[derive(Clone, Debug)] +struct FrozenImagePatch { + rect: RectPoints, + before: RgbaImage, + after: RgbaImage, +} + +#[derive(Clone, Debug)] +struct FrozenMosaicEdit { + preview_patch: FrozenImagePatch, + window_patch: Option, +} + #[derive(Debug)] struct OverlayExitMetadata<'a> { exit_kind: &'static str, diff --git a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs index 86118c5a..59dece3e 100644 --- a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs @@ -37,6 +37,7 @@ impl OverlaySession { self.update_hud_window_position(monitor, global); self.update_live_drag_rect(monitor, global); self.update_frozen_selection_drag_rect(global); + self.update_frozen_mosaic_drag_rect(global); self.sync_overlay_cursor_icons(); self.force_apply_pending_hud_and_loupe_moves(); self.request_redraw_hud_window(); @@ -93,6 +94,7 @@ impl OverlaySession { self.update_hud_window_position(monitor, global); self.update_live_drag_rect(monitor, global); self.update_frozen_selection_drag_rect(global); + self.update_frozen_mosaic_drag_rect(global); self.sync_overlay_cursor_icons(); self.force_apply_pending_hud_and_loupe_moves(); self.request_redraw_hud_window(); diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 55e50493..ac366ffd 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -179,6 +179,26 @@ impl WindowRenderer { Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); } + if let Some(mosaic_preview_rect) = state.frozen_mosaic_preview_rect { + let preview_rect = Self::selection_focus_rect(mosaic_preview_rect, screen_rect); + let preview_fill = match theme { + HudTheme::Dark => Color32::from_rgba_unmultiplied(110, 196, 255, 38), + HudTheme::Light => Color32::from_rgba_unmultiplied(34, 132, 214, 30), + }; + + painter.rect_filled(preview_rect, 10.0, preview_fill); + + has_affordance |= Self::render_selection_dashed_border( + &painter, + preview_rect, + screen_rect, + theme, + Some(2.1), + false, + selection_dashed_border_cache, + ); + } + has_affordance } @@ -2235,15 +2255,15 @@ impl WindowRenderer { for tool in tools { let is_mode_tool = tool.is_mode_tool(); - let action_ready = - !tool.requires_final_capture() || toolbar_state.final_capture_ready; + let action_ready = tool.is_available(toolbar_state) + && (!tool.requires_final_capture() || toolbar_state.final_capture_ready); let response = ui.allocate_response(Vec2::new(button_size, button_size), Sense::click()); let hovered = action_ready && response.hovered(); let response = if action_ready { response.on_hover_text(tool.label()) } else { - response.on_hover_text("Preparing capture...") + response.on_hover_text(tool.unavailable_label(toolbar_state)) }; let hover_anim: f32 = if hovered { 1.0 } else { 0.0 }; diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 33be83f4..c5dabe70 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -143,6 +143,8 @@ pub(super) struct FrozenToolbarState { pub(super) dragging: bool, pub(super) selected_tool: FrozenToolbarTool, pub(super) auto_center_available: bool, + pub(super) undo_available: bool, + pub(super) redo_available: bool, pub(super) scroll_capture_active: bool, pub(super) scroll_capture_available: bool, pub(super) final_capture_ready: bool, @@ -163,6 +165,8 @@ impl Default for FrozenToolbarState { dragging: false, selected_tool: FrozenToolbarTool::Pointer, auto_center_available: false, + undo_available: false, + redo_available: false, scroll_capture_active: false, scroll_capture_available: false, final_capture_ready: false, @@ -203,6 +207,13 @@ impl Default for FrozenSelectionDragState { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct FrozenMosaicDragState { + pub(super) active: bool, + pub(super) anchor_x: u32, + pub(super) anchor_y: u32, +} + #[derive(Default)] pub(super) struct ScrollCaptureState { pub(super) active: bool, diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index f5cf2e54..0542b1b1 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -31,6 +31,8 @@ use egui::RawInput; use egui_phosphor::Variant; use image::Rgba; #[cfg(target_os = "macos")] +use image::imageops; +#[cfg(target_os = "macos")] use winit::dpi::PhysicalPosition; use winit::event::{ElementState, MouseButton, MouseScrollDelta}; #[cfg(target_os = "macos")] @@ -438,6 +440,73 @@ fn begin_ocr_action_drag_region_still_uses_frozen_image_under_matte_mode() { assert!(session.state.error_message.is_none()); } +#[cfg(target_os = "macos")] +#[test] +fn window_matte_mosaic_export_and_ocr_match_preview_pixels() { + let monitor = test_monitor_with_scale(8, 8, 1_000); + let capture_rect = RectPoints::new(2, 1, 4, 4); + let window_id = 7; + let background = image::RgbaImage::from_pixel(8, 8, Rgba([18, 24, 32, 255])); + let window_image = image::RgbaImage::from_fn(4, 4, |x, y| { + let alpha = match (x + y) % 4 { + 0 => 64, + 1 => 112, + 2 => 176, + _ => 224, + }; + + Rgba([ + 40_u8.saturating_add((x * 37) as u8), + 28_u8.saturating_add((y * 41) as u8), + 52_u8.saturating_add(((x + y) * 23) as u8), + alpha, + ]) + }); + let mut session = OverlaySession::new(); + + session.config.window_capture_alpha_mode = WindowCaptureAlphaMode::MatteLight; + + session.state.begin_freeze(monitor); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::Window; + session.inflight_window_freeze_capture = + Some(crate::overlay::WindowFreezeCaptureTarget { monitor, window_id, rect: capture_rect }); + + session.handle_captured_freeze_response( + monitor, + background, + Some(window_image), + Some(window_id), + ); + + assert!(session.authoritative_frozen_capture_ready); + assert!(session.apply_frozen_mosaic_edit(capture_rect)); + + let expected_export = imageops::crop_imm( + session + .state + .frozen_image + .as_ref() + .expect("window matte preview should populate the frozen image"), + capture_rect.x, + capture_rect.y, + capture_rect.width, + capture_rect.height, + ) + .to_image(); + + assert_eq!(session.current_export_image().as_ref(), Some(&expected_export)); + + let control = session.begin_ocr_action(); + let OverlayControl::Exit(OverlayExit::DeferredTextRecognition(request)) = control else { + panic!("expected deferred OCR exit"); + }; + + assert_eq!(request.export_image().as_ref(), Some(&expected_export)); + assert!(session.state.error_message.is_none()); +} + #[cfg(target_os = "macos")] #[test] fn begin_ocr_action_skips_deferred_request_when_drag_region_crop_is_out_of_bounds() { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 5ae47814..c0a6ab8d 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -22,6 +22,36 @@ use crate::overlay::tests::{ use crate::overlay::{FrozenSelectionCorner, FrozenSelectionInteractionKind}; use crate::worker::{WorkerErrorSource, WorkerResponse}; +fn test_mosaic_source_image() -> RgbaImage { + RgbaImage::from_fn(8, 8, |x, y| { + Rgba([(x * 17) as u8, (y * 23) as u8, ((x + y) * 11) as u8, 255]) + }) +} + +fn average_patch_color(image: &RgbaImage, x: u32, y: u32, width: u32, height: u32) -> Rgba { + let mut sum = [0_u64; 4]; + let mut samples = 0_u64; + + for py in y..y.saturating_add(height) { + for px in x..x.saturating_add(width) { + let pixel = image.get_pixel(px, py); + + sum[0] += u64::from(pixel[0]); + sum[1] += u64::from(pixel[1]); + sum[2] += u64::from(pixel[2]); + sum[3] += u64::from(pixel[3]); + samples += 1; + } + } + + Rgba([ + (sum[0] / samples) as u8, + (sum[1] / samples) as u8, + (sum[2] / samples) as u8, + (sum[3] / samples) as u8, + ]) +} + #[cfg(target_os = "macos")] #[test] fn pending_freeze_capture_dispatches_even_with_seeded_preview() { @@ -196,6 +226,85 @@ fn frozen_selection_drag_starts_corner_resize_from_handle_hit_zone() { ); } +#[test] +fn frozen_mosaic_drag_waits_for_final_capture_ready() { + let monitor = tests::test_monitor_with_scale(8, 8, 1_000); + let original = test_mosaic_source_image(); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, original.clone()); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 8, 8)); + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + + assert!(!session.frozen_final_capture_ready()); + assert!(!session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); + assert!(!session.commit_frozen_mosaic_drag()); + assert!(!session.undo_frozen_mosaic_edit()); + assert!(!session.redo_frozen_mosaic_edit()); + assert_eq!(session.state.frozen_mosaic_preview_rect, None); + assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); + + session.authoritative_frozen_capture_ready = true; + + assert!(session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); +} + +#[test] +fn frozen_mosaic_drag_updates_preview_rect_inside_capture_bounds() { + let monitor = tests::test_monitor_with_scale(8, 8, 1_000); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, test_mosaic_source_image()); + + session.state.frozen_capture_rect = Some(RectPoints::new(2, 2, 4, 4)); + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + session.authoritative_frozen_capture_ready = true; + + assert!(session.begin_frozen_mosaic_drag(GlobalPoint::new(3, 3))); + assert!(session.update_frozen_mosaic_drag_rect(GlobalPoint::new(30, 30))); + assert_eq!(session.state.frozen_mosaic_preview_rect, Some(RectPoints::new(3, 3, 3, 3))); +} + +#[test] +fn frozen_mosaic_commit_round_trips_through_undo_and_redo() { + let monitor = tests::test_monitor_with_scale(8, 8, 1_000); + let original = test_mosaic_source_image(); + let expected_fill = average_patch_color(&original, 1, 1, 4, 4); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, original.clone()); + + session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 8, 8)); + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + session.authoritative_frozen_capture_ready = true; + + assert!(session.begin_frozen_mosaic_drag(GlobalPoint::new(1, 1))); + assert!(session.update_frozen_mosaic_drag_rect(GlobalPoint::new(4, 4))); + assert!(session.commit_frozen_mosaic_drag()); + + let edited = + session.state.frozen_image.clone().expect("mosaic commit should retain the frozen image"); + + assert_eq!(edited.get_pixel(2, 2), &expected_fill); + assert_eq!(edited.get_pixel(4, 4), &expected_fill); + assert_ne!(edited, original); + assert_eq!(session.state.frozen_mosaic_preview_rect, None); + assert!(session.toolbar_state.undo_available); + assert!(!session.toolbar_state.redo_available); + assert!(session.undo_frozen_mosaic_edit()); + assert_eq!(session.state.frozen_image.as_ref(), Some(&original)); + assert!(!session.toolbar_state.undo_available); + assert!(session.toolbar_state.redo_available); + assert!(session.redo_frozen_mosaic_edit()); + assert_eq!(session.state.frozen_image.as_ref(), Some(&edited)); + assert!(session.toolbar_state.undo_available); + assert!(!session.toolbar_state.redo_available); +} + #[test] fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { let monitor = tests::test_monitor(); @@ -514,6 +623,31 @@ fn auto_center_frozen_capture_rect_recenters_detected_content() { assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); } +#[test] +fn auto_center_frozen_capture_rect_works_outside_pointer_mode() { + let monitor = tests::test_monitor_with_scale(80, 60, 2_000); + let capture_rect = RectPoints::new(20, 16, 40, 24); + let mut image = RgbaImage::from_pixel(160, 120, Rgba([14, 16, 20, 255])); + let mut session = OverlaySession::new(); + + for y in 40..52 { + for x in 52..68 { + image.put_pixel(x, y, Rgba([228, 232, 240, 255])); + } + } + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, image); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + + assert!(session.frozen_auto_center_available()); + assert!(session.auto_center_frozen_capture_rect()); + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(10, 11, 40, 24))); +} + #[test] fn frozen_selection_resize_hit_test_prefers_corner_handles() { let capture_rect = RectPoints::new(100, 120, 8, 8); diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index a5c4a22e..09085158 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -37,6 +37,16 @@ impl OverlaySession { self.toolbar_cursor_global_position(toolbar_window, cursor_local) { self.update_frozen_selection_drag_rect(global_cursor); + self.update_frozen_mosaic_drag_rect(global_cursor); + } + + return OverlayControl::Continue; + } + if self.frozen_mosaic_drag.active { + if let Some(global_cursor) = + self.toolbar_cursor_global_position(toolbar_window, cursor_local) + { + self.update_frozen_mosaic_drag_rect(global_cursor); } return OverlayControl::Continue; @@ -352,6 +362,7 @@ impl OverlaySession { if self.toolbar_state.needs_redraw { self.toolbar_state.needs_redraw = false; + self.request_redraw_for_monitor(monitor); self.request_redraw_toolbar_window(); } if tracing::enabled!(tracing::Level::TRACE) diff --git a/packages/rsnap-overlay/src/state.rs b/packages/rsnap-overlay/src/state.rs index 9b021917..551daf77 100644 --- a/packages/rsnap-overlay/src/state.rs +++ b/packages/rsnap-overlay/src/state.rs @@ -305,6 +305,7 @@ pub struct OverlayState { pub hovered_window_rect: Option, pub drag_rect: Option, pub frozen_capture_rect: Option, + pub frozen_mosaic_preview_rect: Option, pub live_bg_monitor: Option, pub live_bg_image: Option, pub live_bg_generation: u64, @@ -325,6 +326,7 @@ impl OverlayState { hovered_window_rect: None, drag_rect: None, frozen_capture_rect: None, + frozen_mosaic_preview_rect: None, live_bg_monitor: None, live_bg_image: None, live_bg_generation: 0, @@ -353,6 +355,7 @@ impl OverlayState { pub fn begin_freeze(&mut self, monitor: MonitorRect) { self.monitor = Some(monitor); self.frozen_image = None; + self.frozen_mosaic_preview_rect = None; self.loupe = None; self.mode = OverlayMode::Frozen; self.frozen_generation = self.frozen_generation.wrapping_add(1);