From c7c61351d43a91ae3f4c0f03e79086e41cf19598 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sun, 5 Apr 2026 12:10:22 +0800 Subject: [PATCH 1/2] {"schema":"delivery/1","type":"feat","scope":"rsnap-overlay","summary":"add frozen selection resize affordances","intent":"let drag-region freezes stay editable in place with clearer resize guidance and more stable frozen-selection layout behavior","impact":"drag-region frozen captures now support corner resize handles, resize cursors, live dashed selection borders, corner-aware dash spacing, and badge/toolbar refinements covered by regression tests","breaking":false,"risk":"medium","authority":"user","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/overlay.rs | 262 ++++++++++- .../src/overlay/cursor_runtime.rs | 4 + .../rsnap-overlay/src/overlay/hud_runtime.rs | 1 + .../rsnap-overlay/src/overlay/rendering.rs | 26 +- .../src/overlay/rendering/affordances.rs | 427 +++++++++++++++++- .../src/overlay/session_state.rs | 27 +- .../src/overlay/tests/rendering_behaviors.rs | 385 ++++++++++++++-- .../src/overlay/toolbar_runtime.rs | 1 + 8 files changed, 1050 insertions(+), 83 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 7b433e01..05f11361 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -130,7 +130,7 @@ use winit::{ event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::ActiveEventLoop, keyboard::{Key, ModifiersState, NamedKey}, - window::{WindowId, WindowLevel}, + window::{CursorIcon, WindowId, WindowLevel}, }; #[cfg(target_os = "macos")] @@ -323,10 +323,16 @@ const LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT: u8 = 96; const LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK: u8 = 148; const FROZEN_SELECTION_SCRIM_ALPHA_LIGHT: u8 = 224; const FROZEN_SELECTION_SCRIM_ALPHA_DARK: u8 = 208; -const SELECTION_DASHED_BORDER_WIDTH_PX: f32 = 2.0; -const SELECTION_DASHED_BORDER_DASH_LENGTH_PX: f32 = 6.0; -const SELECTION_DASHED_BORDER_GAP_LENGTH_PX: f32 = 4.0; -const SELECTION_DASHED_BORDER_ALPHA: u8 = 224; +const SELECTION_DASHED_BORDER_WIDTH_PX: f32 = 3.1; +const SELECTION_DASHED_BORDER_DASH_LENGTH_PX: f32 = 12.0; +const SELECTION_DASHED_BORDER_GAP_LENGTH_PX: f32 = 7.8; +const SELECTION_DASHED_BORDER_ALPHA: u8 = 248; +const FROZEN_SELECTION_RESIZE_HANDLE_HIT_SIZE_POINTS: f32 = 24.0; +const FROZEN_SELECTION_RESIZE_HANDLE_HIT_OFFSET_POINTS: f32 = 4.0; +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 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; @@ -572,6 +578,21 @@ enum FrozenCaptureSource { FullscreenFallback, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrozenSelectionCorner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum FrozenSelectionInteractionKind { + #[default] + Move, + Resize(FrozenSelectionCorner), +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DeviceCursorPointSource { DevicePoints, @@ -1634,20 +1655,96 @@ impl OverlaySession { let Some((cursor_x, cursor_y)) = monitor.local_u32(global) else { return false; }; - - if !capture_rect.contains((cursor_x, cursor_y)) { + let Some(interaction) = + Self::frozen_selection_interaction_kind(capture_rect, cursor_x, cursor_y) + else { return false; - } + }; self.frozen_selection_drag = FrozenSelectionDragState { active: true, + interaction, + anchor_rect: capture_rect, pointer_offset_x: cursor_x.saturating_sub(capture_rect.x), pointer_offset_y: cursor_y.saturating_sub(capture_rect.y), + press_cursor_x: cursor_x, + press_cursor_y: cursor_y, }; true } + fn frozen_selection_interaction_kind( + capture_rect: RectPoints, + cursor_x: u32, + cursor_y: u32, + ) -> Option { + if let Some(corner) = WindowRenderer::frozen_selection_resize_hit_test( + capture_rect, + Pos2::new(cursor_x as f32, cursor_y as f32), + ) { + return Some(FrozenSelectionInteractionKind::Resize(corner)); + } + + capture_rect.contains((cursor_x, cursor_y)).then_some(FrozenSelectionInteractionKind::Move) + } + + fn frozen_selection_resize_cursor_icon(corner: FrozenSelectionCorner) -> CursorIcon { + match corner { + FrozenSelectionCorner::TopLeft => CursorIcon::NwResize, + FrozenSelectionCorner::TopRight => CursorIcon::NeResize, + FrozenSelectionCorner::BottomLeft => CursorIcon::SwResize, + FrozenSelectionCorner::BottomRight => CursorIcon::SeResize, + } + } + + fn frozen_selection_cursor_icon_for_monitor(&self, monitor: MonitorRect) -> CursorIcon { + let Some((target_monitor, capture_rect)) = self.frozen_selection_drag_target() else { + return CursorIcon::Default; + }; + if target_monitor != monitor { + return CursorIcon::Default; + } + + if self.frozen_selection_drag.active { + return match self.frozen_selection_drag.interaction { + FrozenSelectionInteractionKind::Resize(corner) => { + Self::frozen_selection_resize_cursor_icon(corner) + }, + FrozenSelectionInteractionKind::Move => CursorIcon::Default, + }; + } + + let Some(cursor) = self.state.cursor else { + return CursorIcon::Default; + }; + let Some((cursor_x, cursor_y)) = monitor.local_u32(cursor) else { + return CursorIcon::Default; + }; + + match Self::frozen_selection_interaction_kind(capture_rect, cursor_x, cursor_y) { + Some(FrozenSelectionInteractionKind::Resize(corner)) => { + Self::frozen_selection_resize_cursor_icon(corner) + }, + _ => CursorIcon::Default, + } + } + + fn overlay_cursor_icon_for_monitor(&self, monitor: MonitorRect) -> CursorIcon { + match self.state.mode { + OverlayMode::Frozen => self.frozen_selection_cursor_icon_for_monitor(monitor), + OverlayMode::Live => CursorIcon::Default, + } + } + + fn sync_overlay_cursor_icons(&self) { + for overlay_window in self.windows.values() { + overlay_window + .window + .set_cursor(self.overlay_cursor_icon_for_monitor(overlay_window.monitor)); + } + } + fn stop_frozen_selection_drag(&mut self) { self.frozen_selection_drag = FrozenSelectionDragState::default(); } @@ -1657,23 +1754,41 @@ impl OverlaySession { return false; } - let Some((monitor, capture_rect)) = self.frozen_selection_drag_target() else { + let Some((monitor, _capture_rect)) = self.frozen_selection_drag_target() else { self.stop_frozen_selection_drag(); return false; }; - let (cursor_x, cursor_y) = Self::clamped_local_point_in_monitor(monitor, global); - let desired_x = - i64::from(cursor_x) - i64::from(self.frozen_selection_drag.pointer_offset_x); - let desired_y = - i64::from(cursor_y) - i64::from(self.frozen_selection_drag.pointer_offset_y); - let next_rect = Self::clamp_frozen_capture_rect_to_monitor( - monitor, - capture_rect.width, - capture_rect.height, - desired_x, - desired_y, - ); + let anchor_rect = self.frozen_selection_drag.anchor_rect; + let next_rect = match self.frozen_selection_drag.interaction { + FrozenSelectionInteractionKind::Move => { + let (cursor_x, cursor_y) = Self::clamped_local_point_in_monitor(monitor, global); + let desired_x = + i64::from(cursor_x) - i64::from(self.frozen_selection_drag.pointer_offset_x); + let desired_y = + i64::from(cursor_y) - i64::from(self.frozen_selection_drag.pointer_offset_y); + + Self::clamp_frozen_capture_rect_to_monitor( + monitor, + anchor_rect.width, + anchor_rect.height, + desired_x, + desired_y, + ) + }, + FrozenSelectionInteractionKind::Resize(corner) => { + let (cursor_x, cursor_y) = Self::local_point_in_monitor_space(monitor, global); + Self::resize_frozen_capture_rect_from_corner( + monitor, + anchor_rect, + corner, + self.frozen_selection_drag.press_cursor_x, + self.frozen_selection_drag.press_cursor_y, + cursor_x, + cursor_y, + ) + }, + }; self.apply_frozen_capture_rect_update(monitor, next_rect) } @@ -1687,6 +1802,13 @@ impl OverlaySession { (local_x, local_y) } + fn local_point_in_monitor_space(monitor: MonitorRect, global: GlobalPoint) -> (i64, i64) { + ( + i64::from(global.x) - i64::from(monitor.origin.x), + i64::from(global.y) - i64::from(monitor.origin.y), + ) + } + fn clamp_frozen_capture_rect_to_monitor( monitor: MonitorRect, width: u32, @@ -1702,6 +1824,76 @@ impl OverlaySession { RectPoints::new(x, y, width, height) } + fn resize_frozen_capture_rect_from_corner( + monitor: MonitorRect, + anchor_rect: RectPoints, + corner: FrozenSelectionCorner, + press_cursor_x: u32, + press_cursor_y: u32, + cursor_x: i64, + cursor_y: i64, + ) -> RectPoints { + let left = i64::from(anchor_rect.x); + let top = i64::from(anchor_rect.y); + let right = i64::from(anchor_rect.x.saturating_add(anchor_rect.width)); + let bottom = i64::from(anchor_rect.y.saturating_add(anchor_rect.height)); + let delta_x = cursor_x - i64::from(press_cursor_x); + let delta_y = cursor_y - i64::from(press_cursor_y); + let monitor_width = i64::from(monitor.width); + let monitor_height = i64::from(monitor.height); + + match corner { + FrozenSelectionCorner::TopLeft => { + let next_left = (left + delta_x).clamp(0, right.saturating_sub(1)) as u32; + let next_top = (top + delta_y).clamp(0, bottom.saturating_sub(1)) as u32; + + RectPoints::new( + next_left, + next_top, + (right as u32).saturating_sub(next_left), + (bottom as u32).saturating_sub(next_top), + ) + }, + FrozenSelectionCorner::TopRight => { + let next_right = + (right + delta_x).clamp(left.saturating_add(1), monitor_width) as u32; + let next_top = (top + delta_y).clamp(0, bottom.saturating_sub(1)) as u32; + + RectPoints::new( + left as u32, + next_top, + next_right.saturating_sub(left as u32), + (bottom as u32).saturating_sub(next_top), + ) + }, + FrozenSelectionCorner::BottomLeft => { + let next_left = (left + delta_x).clamp(0, right.saturating_sub(1)) as u32; + let next_bottom = + (bottom + delta_y).clamp(top.saturating_add(1), monitor_height) as u32; + + RectPoints::new( + next_left, + top as u32, + (right as u32).saturating_sub(next_left), + next_bottom.saturating_sub(top as u32), + ) + }, + FrozenSelectionCorner::BottomRight => { + let next_right = + (right + delta_x).clamp(left.saturating_add(1), monitor_width) as u32; + let next_bottom = + (bottom + delta_y).clamp(top.saturating_add(1), monitor_height) as u32; + + RectPoints::new( + left as u32, + top as u32, + next_right.saturating_sub(left as u32), + next_bottom.saturating_sub(top as u32), + ) + }, + } + } + fn apply_frozen_capture_rect_update( &mut self, monitor: MonitorRect, @@ -1713,9 +1905,21 @@ impl OverlaySession { self.state.frozen_capture_rect = Some(next_rect); - let toolbar_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, next_rect); + let toolbar_default_pos = + self.frozen_toolbar_default_position_for_capture_rect(monitor, next_rect); + let toolbar_pos = match ( + self.toolbar_state.floating_position, + self.toolbar_state.default_slot_position, + ) { + (Some(floating_pos), Some(default_pos)) + if !frozen_toolbar_matches_default_slot(floating_pos, default_pos) => + { + floating_pos + }, + _ => toolbar_default_pos, + }; - self.toolbar_state.default_slot_position = Some(toolbar_pos); + self.toolbar_state.default_slot_position = Some(toolbar_default_pos); self.toolbar_state.floating_position = Some(toolbar_pos); let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); @@ -2278,6 +2482,7 @@ impl OverlaySession { ) { if state == ElementState::Released && button == MouseButton::Left { self.stop_frozen_selection_drag(); + self.sync_overlay_cursor_icons(); } } @@ -2894,6 +3099,7 @@ impl OverlaySession { self.update_live_drag_rect(monitor, global); self.update_frozen_selection_drag_rect(global); + self.sync_overlay_cursor_icons(); self.request_cursor_move_samples(monitor, global); if let Some(old_monitor) = old_monitor @@ -2951,6 +3157,7 @@ impl OverlaySession { self.update_live_drag_rect(monitor, global); self.update_frozen_selection_drag_rect(global); + self.sync_overlay_cursor_icons(); self.request_cursor_move_samples(monitor, global); if let Some(old_monitor) = old_monitor @@ -3108,8 +3315,12 @@ impl OverlaySession { ElementState::Pressed => { let cursor = self.current_device_cursor(); let _ = self.begin_frozen_selection_drag(cursor); + self.sync_overlay_cursor_icons(); + }, + ElementState::Released => { + self.stop_frozen_selection_drag(); + self.sync_overlay_cursor_icons(); }, - ElementState::Released => self.stop_frozen_selection_drag(), } self.request_redraw_for_monitor(monitor); @@ -4704,6 +4915,7 @@ impl OverlaySession { return OverlayControl::Continue; }; + self.sync_overlay_cursor_icons(); self.sync_frozen_toolbar_state(); self.event_loop_last_progress_window_id = Some(window_id); @@ -4768,6 +4980,7 @@ impl OverlaySession { overlay_screen_rect, toolbar_ready_for_badge, ); + let frozen_selection_resize_handles_enabled = self.frozen_selection_drag_target().is_some(); let Some(gpu) = self.gpu.as_ref() else { return self.exit(OverlayExit::Error(String::from("Missing GPU context"))); }; @@ -4799,6 +5012,7 @@ impl OverlaySession { self.config.selection_flow_stroke_width_px, !self.scroll_capture.active, self.scroll_capture.active, + frozen_selection_resize_handles_enabled, self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, frozen_toolbar_reserved_rect, diff --git a/packages/rsnap-overlay/src/overlay/cursor_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_runtime.rs index 4b5030ff..86118c5a 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.sync_overlay_cursor_icons(); self.force_apply_pending_hud_and_loupe_moves(); self.request_redraw_hud_window(); @@ -92,6 +93,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.sync_overlay_cursor_icons(); self.force_apply_pending_hud_and_loupe_moves(); self.request_redraw_hud_window(); @@ -148,6 +150,7 @@ impl OverlaySession { self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); self.update_live_drag_rect(monitor, global); + self.sync_overlay_cursor_icons(); if let Some(old_monitor) = old_monitor && old_monitor != monitor @@ -198,6 +201,7 @@ impl OverlaySession { self.update_cursor_for_live_move(old_monitor, old_cursor, monitor, global); self.update_live_drag_rect(monitor, global); + self.sync_overlay_cursor_icons(); if let Some(old_monitor) = old_monitor && old_monitor != monitor diff --git a/packages/rsnap-overlay/src/overlay/hud_runtime.rs b/packages/rsnap-overlay/src/overlay/hud_runtime.rs index 16c790de..3d79e921 100644 --- a/packages/rsnap-overlay/src/overlay/hud_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/hud_runtime.rs @@ -109,6 +109,7 @@ impl OverlaySession { self.config.selection_flow_stroke_width_px, true, false, + false, self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, None, diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index 50483a62..10f0bada 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -16,11 +16,11 @@ use crate::overlay::{ BindingType, BlendState, Buffer, BufferBindingType, BufferSize, BufferUsages, ClippedPrimitive, Color32, ColorWrites, CompositeAlphaMode, Cow, CurrentSurfaceTexture, Device, Duration, Event, ExperimentalFeatures, Features, FilterMode, FontDefinitions, FontFamily, FrontFace, - FrozenCaptureSource, FrozenToolbarPointerState, FrozenToolbarState, FullOutput, HudAnchor, - HudTheme, Id, Instant, LayerId, LoadOp, MemoryHints, MipmapFilterMode, MonitorRect, - MultisampleState, Mutex, Order, OverlayMode, OverlaySession, OverlayState, PhysicalSize, - PipelineCompilationOptions, PointerButton, PolygonMode, Pos2, PowerPreference, PresentMode, - PrimitiveTopology, Queue, Rect, RectPoints, RenderPipeline, Renderer, Result, + FrozenCaptureSource, FrozenSelectionCorner, FrozenToolbarPointerState, FrozenToolbarState, + FullOutput, HudAnchor, HudTheme, Id, Instant, LayerId, LoadOp, MemoryHints, MipmapFilterMode, + MonitorRect, MultisampleState, Mutex, Order, OverlayMode, OverlaySession, OverlayState, + PhysicalSize, PipelineCompilationOptions, PointerButton, PolygonMode, Pos2, PowerPreference, + PresentMode, PrimitiveTopology, Queue, Rect, RectPoints, RenderPipeline, Renderer, Result, SLOW_OP_WARN_RENDER, Sampler, SamplerBindingType, ScreenDescriptor, ShaderSource, ShaderStages, SlowOperationLogger, StoreOp, Surface, SurfaceCapabilities, SurfaceFrameSkipReason, SurfaceTexture, Texture, TextureAspect, TextureSampleType, TextureUsages, TextureView, @@ -55,9 +55,10 @@ pub(super) struct SelectionDashedBorderCacheKey { rect_max_y_bits: u32, dash_length_bits: u32, gap_length_bits: u32, + corner_keepout_bits: u32, } impl SelectionDashedBorderCacheKey { - const fn new(rect: Rect, dash_length: f32, gap_length: f32) -> Self { + const fn new(rect: Rect, dash_length: f32, gap_length: f32, corner_keepout: f32) -> Self { Self { rect_min_x_bits: rect.min.x.to_bits(), rect_min_y_bits: rect.min.y.to_bits(), @@ -65,6 +66,7 @@ impl SelectionDashedBorderCacheKey { rect_max_y_bits: rect.max.y.to_bits(), dash_length_bits: dash_length.to_bits(), gap_length_bits: gap_length.to_bits(), + corner_keepout_bits: corner_keepout.to_bits(), } } } @@ -95,6 +97,13 @@ pub(super) struct SelectionSizeBadgeTarget { pub(super) size_points: RectPoints, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct FrozenSelectionResizeHandleGeometry { + pub(super) corner: FrozenSelectionCorner, + pub(super) anchor: Pos2, + pub(super) hit_rect: Rect, +} + pub(super) struct HudOverlayWindow { pub(super) window: Arc, pub(super) renderer: WindowRenderer, @@ -667,6 +676,7 @@ impl WindowRenderer { selection_flow_stroke_width_px: f32, needs_frozen_surface_bg: bool, show_frozen_capture_affordance: bool, + frozen_selection_resize_handles_enabled: bool, frozen_capture_source: FrozenCaptureSource, frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, @@ -747,6 +757,7 @@ impl WindowRenderer { selection_flow_enabled, selection_flow_stroke_width_px, selection_flow_geometry_cache, + selection_dashed_border_cache, ); } if matches!(state.mode, OverlayMode::Frozen) @@ -762,6 +773,7 @@ impl WindowRenderer { monitor, screen_rect, theme, + frozen_selection_resize_handles_enabled, frozen_capture_source, frozen_toolbar_reserved_rect, frozen_capture_is_fullscreen_fallback, @@ -1292,6 +1304,7 @@ impl WindowRenderer { selection_flow_stroke_width_px: f32, allow_frozen_surface_bg: bool, show_frozen_capture_affordance: bool, + frozen_selection_resize_handles_enabled: bool, frozen_capture_source: FrozenCaptureSource, frozen_capture_is_fullscreen_fallback: bool, frozen_toolbar_reserved_rect: Option, @@ -1353,6 +1366,7 @@ impl WindowRenderer { selection_flow_stroke_width_px, hud_cfg.needs_frozen_surface_bg, show_frozen_capture_affordance, + frozen_selection_resize_handles_enabled, frozen_capture_source, frozen_capture_is_fullscreen_fallback, frozen_toolbar_reserved_rect, diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 9b2354d0..d8899633 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -2,16 +2,23 @@ use egui::Context; #[allow(unused_imports)] use crate::overlay::rendering::{ - FrozenToolbarButtonStyle, SelectionDashedBorderCache, SelectionDashedBorderCacheKey, - SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionFlowGeometryCacheKey, - SelectionSizeBadgeLayout, SelectionSizeBadgePadding, SelectionSizeBadgeTarget, WindowRenderer, + FrozenSelectionResizeHandleGeometry, FrozenToolbarButtonStyle, SelectionDashedBorderCache, + SelectionDashedBorderCacheKey, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, + SelectionFlowGeometryCacheKey, SelectionSizeBadgeLayout, SelectionSizeBadgePadding, + SelectionSizeBadgeTarget, WindowRenderer, }; #[allow(unused_imports)] use crate::overlay::{ - self, Align, Align2, Area, Color32, CornerRadius, FROZEN_SELECTION_SCRIM_ALPHA_DARK, + self, Align, Align2, Area, Color32, CornerRadius, + FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_BORDER_GAP_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_HIT_OFFSET_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_HIT_SIZE_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS, FROZEN_SELECTION_SCRIM_ALPHA_DARK, FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, FrozenCaptureSource, - FrozenToolbarPointerState, FrozenToolbarState, FrozenToolbarTool, + FrozenSelectionCorner, FrozenToolbarPointerState, FrozenToolbarState, FrozenToolbarTool, HUD_PILL_CORNER_RADIUS_POINTS, HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_INNER_MARGIN_Y_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, HudPillGeometry, HudTheme, Id, LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, @@ -31,6 +38,13 @@ use crate::overlay::{ Ui, UiBuilder, Vec2, regular, }; +#[derive(Clone, Copy)] +pub(in crate::overlay) struct SelectionScrimStyle { + pub(in crate::overlay) scrim_fill: Color32, + pub(in crate::overlay) stroke_width_override: Option, + pub(in crate::overlay) exclude_resize_handle_corners: bool, +} + impl WindowRenderer { #[allow(clippy::too_many_arguments)] pub(in crate::overlay) fn render_live_capture_affordances( @@ -43,6 +57,7 @@ impl WindowRenderer { selection_flow_enabled: bool, selection_flow_stroke_width_px: f32, selection_flow_geometry_cache: &mut SelectionFlowGeometryCache, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, ) -> bool { let mut has_rect = false; @@ -82,7 +97,13 @@ impl WindowRenderer { } } if let Some(rect) = Self::live_drag_focus_rect(state, monitor, screen_rect) { - Self::render_live_drag_selection_scrim(painter, rect, screen_rect, theme); + Self::render_live_drag_selection_affordance( + painter, + rect, + screen_rect, + theme, + selection_dashed_border_cache, + ); has_rect = true; } @@ -138,6 +159,7 @@ impl WindowRenderer { monitor: MonitorRect, screen_rect: Rect, theme: HudTheme, + frozen_selection_resize_handles_enabled: bool, frozen_capture_source: FrozenCaptureSource, frozen_toolbar_reserved_rect: Option, frozen_capture_is_fullscreen_fallback: bool, @@ -152,6 +174,8 @@ impl WindowRenderer { let layer = LayerId::new(Order::Foreground, Id::new(format!("frozen-pending-{}", monitor.id))); let painter = ctx.layer_painter(layer); + let show_resize_handles = frozen_selection_resize_handles_enabled + && frozen_capture_source == FrozenCaptureSource::DragRegion; if state.frozen_image.is_some() { let mut has_affordance = Self::render_frozen_selection_scrim( @@ -159,6 +183,7 @@ impl WindowRenderer { rect, screen_rect, theme, + show_resize_handles, selection_dashed_border_cache, ); @@ -176,6 +201,10 @@ impl WindowRenderer { has_affordance = true; } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { + has_affordance |= + Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); + } return has_affordance; } @@ -185,6 +214,7 @@ impl WindowRenderer { rect, screen_rect, theme, + show_resize_handles, selection_dashed_border_cache, ); @@ -202,6 +232,10 @@ impl WindowRenderer { has_affordance = true; } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { + has_affordance |= + Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); + } return has_affordance; } @@ -232,6 +266,9 @@ impl WindowRenderer { theme, ); } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { + Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); + } true } @@ -328,6 +365,121 @@ impl WindowRenderer { Self::selection_size_badge_target_from_rect(capture_rect, screen_rect) } + pub(in crate::overlay) fn frozen_selection_resize_handles( + capture_rect: RectPoints, + ) -> [FrozenSelectionResizeHandleGeometry; 4] { + let rect = Rect::from_min_size( + Pos2::new(capture_rect.x as f32, capture_rect.y as f32), + Vec2::new(capture_rect.width as f32, capture_rect.height as f32), + ); + + [ + Self::frozen_selection_resize_handle(FrozenSelectionCorner::TopLeft, rect.min), + Self::frozen_selection_resize_handle( + FrozenSelectionCorner::TopRight, + Pos2::new(rect.max.x, rect.min.y), + ), + Self::frozen_selection_resize_handle( + FrozenSelectionCorner::BottomLeft, + Pos2::new(rect.min.x, rect.max.y), + ), + Self::frozen_selection_resize_handle(FrozenSelectionCorner::BottomRight, rect.max), + ] + } + + fn frozen_selection_resize_handle( + corner: FrozenSelectionCorner, + anchor: Pos2, + ) -> FrozenSelectionResizeHandleGeometry { + let hit_size = Vec2::splat(FROZEN_SELECTION_RESIZE_HANDLE_HIT_SIZE_POINTS); + let hit_offset = FROZEN_SELECTION_RESIZE_HANDLE_HIT_OFFSET_POINTS; + let hit_center = match corner { + FrozenSelectionCorner::TopLeft => { + Pos2::new(anchor.x - hit_offset, anchor.y - hit_offset) + }, + FrozenSelectionCorner::TopRight => { + Pos2::new(anchor.x + hit_offset, anchor.y - hit_offset) + }, + FrozenSelectionCorner::BottomLeft => { + Pos2::new(anchor.x - hit_offset, anchor.y + hit_offset) + }, + FrozenSelectionCorner::BottomRight => { + Pos2::new(anchor.x + hit_offset, anchor.y + hit_offset) + }, + }; + + FrozenSelectionResizeHandleGeometry { + corner, + anchor, + hit_rect: Rect::from_center_size(hit_center, hit_size), + } + } + + pub(in crate::overlay) fn frozen_selection_resize_hit_test( + capture_rect: RectPoints, + cursor_local: Pos2, + ) -> Option { + let rect = Rect::from_min_size( + Pos2::new(capture_rect.x as f32, capture_rect.y as f32), + Vec2::new(capture_rect.width as f32, capture_rect.height as f32), + ); + let mut best_corner = None; + let mut best_distance_sq = f32::MAX; + + for handle in Self::frozen_selection_resize_handles(capture_rect) { + if !handle.hit_rect.contains(cursor_local) { + continue; + } + if rect.contains(cursor_local) + && !Self::frozen_selection_resize_handle_interior_hit( + handle.corner, + rect, + cursor_local, + ) { + continue; + } + + let distance_sq = handle.anchor.distance_sq(cursor_local); + + if distance_sq < best_distance_sq { + best_corner = Some(handle.corner); + best_distance_sq = distance_sq; + } + } + + best_corner + } + + fn frozen_selection_resize_handle_interior_hit( + corner: FrozenSelectionCorner, + rect: Rect, + cursor_local: Pos2, + ) -> bool { + let interior_reach_x = + (rect.width() * 0.35).min(FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS); + let interior_reach_y = + (rect.height() * 0.35).min(FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS); + + match corner { + FrozenSelectionCorner::TopLeft => { + cursor_local.x <= rect.min.x + interior_reach_x + && cursor_local.y <= rect.min.y + interior_reach_y + }, + FrozenSelectionCorner::TopRight => { + cursor_local.x >= rect.max.x - interior_reach_x + && cursor_local.y <= rect.min.y + interior_reach_y + }, + FrozenSelectionCorner::BottomLeft => { + cursor_local.x <= rect.min.x + interior_reach_x + && cursor_local.y >= rect.max.y - interior_reach_y + }, + FrozenSelectionCorner::BottomRight => { + cursor_local.x >= rect.max.x - interior_reach_x + && cursor_local.y >= rect.max.y - interior_reach_y + }, + } + } + pub(in crate::overlay) fn frozen_toolbar_reserved_rect( state: &OverlayState, monitor: MonitorRect, @@ -726,13 +878,19 @@ impl WindowRenderer { focus_rect: Rect, screen_rect: Rect, theme: HudTheme, + exclude_resize_handle_corners: bool, selection_dashed_border_cache: &mut SelectionDashedBorderCache, ) -> bool { Self::render_selection_scrim( painter, focus_rect, screen_rect, - Self::frozen_selection_scrim_color(theme), + theme, + SelectionScrimStyle { + scrim_fill: Self::frozen_selection_scrim_color(theme), + stroke_width_override: Some(FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS), + exclude_resize_handle_corners, + }, selection_dashed_border_cache, ) } @@ -751,19 +909,44 @@ impl WindowRenderer { ) } + pub(in crate::overlay) fn render_live_drag_selection_affordance( + painter: &Painter, + focus_rect: Rect, + screen_rect: Rect, + theme: HudTheme, + selection_dashed_border_cache: &mut SelectionDashedBorderCache, + ) -> bool { + Self::render_selection_scrim( + painter, + focus_rect, + screen_rect, + theme, + SelectionScrimStyle { + scrim_fill: Self::live_drag_selection_scrim_color(theme), + stroke_width_override: None, + exclude_resize_handle_corners: false, + }, + selection_dashed_border_cache, + ) + } + pub(in crate::overlay) fn render_selection_scrim( painter: &Painter, focus_rect: Rect, screen_rect: Rect, - scrim_fill: Color32, + theme: HudTheme, + style: SelectionScrimStyle, selection_dashed_border_cache: &mut SelectionDashedBorderCache, ) -> bool { let drew_scrim = - Self::render_selection_scrim_fill(painter, focus_rect, screen_rect, scrim_fill); + Self::render_selection_scrim_fill(painter, focus_rect, screen_rect, style.scrim_fill); let drew_border = Self::render_selection_dashed_border( painter, focus_rect, screen_rect, + theme, + style.stroke_width_override, + style.exclude_resize_handle_corners, selection_dashed_border_cache, ); @@ -796,9 +979,16 @@ impl WindowRenderer { painter: &Painter, focus_rect: Rect, screen_rect: Rect, + theme: HudTheme, + stroke_width_override: Option, + exclude_resize_handle_corners: bool, selection_dashed_border_cache: &mut SelectionDashedBorderCache, ) -> bool { - let metrics = Self::selection_dashed_border_metrics(painter.pixels_per_point()); + let mut metrics = Self::selection_dashed_border_metrics(painter.pixels_per_point()); + + if let Some(stroke_width) = stroke_width_override { + metrics.stroke_width = stroke_width; + } let border_outset = Self::selection_dashed_border_outset(metrics.stroke_width, painter.pixels_per_point()); let Some(border_rect) = @@ -806,29 +996,47 @@ impl WindowRenderer { else { return false; }; + let corner_keepout = exclude_resize_handle_corners + .then_some(FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS + metrics.gap_length); let segments = Self::selection_dashed_border_cached_segments( selection_dashed_border_cache, border_rect, metrics.dash_length, metrics.gap_length, + corner_keepout.unwrap_or(0.0), ); if segments.is_empty() { return false; } - let stroke = Stroke::new( - metrics.stroke_width, - Color32::from_rgba_unmultiplied(255, 255, 255, SELECTION_DASHED_BORDER_ALPHA), - ); + let (outline_stroke, stroke) = Self::selection_dashed_border_strokes(metrics, theme); for segment in segments { + painter.add(Shape::line_segment(*segment, outline_stroke)); painter.add(Shape::line_segment(*segment, stroke)); } true } + fn selection_dashed_border_strokes( + metrics: SelectionDashedBorderMetrics, + theme: HudTheme, + ) -> (Stroke, Stroke) { + let _ = theme; + let outline = Stroke::new( + metrics.stroke_width + 1.15, + Color32::from_rgba_unmultiplied(229, 247, 255, 116), + ); + let stroke = Stroke::new( + metrics.stroke_width, + Color32::from_rgba_unmultiplied(167, 223, 255, SELECTION_DASHED_BORDER_ALPHA), + ); + + (outline, stroke) + } + pub(in crate::overlay) fn selection_dashed_border_metrics( pixels_per_point: f32, ) -> SelectionDashedBorderMetrics { @@ -841,6 +1049,78 @@ impl WindowRenderer { } } + fn frozen_selection_resize_handle_outline_stroke(theme: HudTheme) -> Stroke { + let _ = theme; + let color = Color32::from_rgba_unmultiplied(229, 247, 255, 124); + + Stroke::new(FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS + 0.95, color) + } + + fn frozen_selection_resize_handle_stroke(theme: HudTheme) -> Stroke { + let _ = theme; + + Stroke::new( + FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS, + Color32::from_rgba_unmultiplied(167, 223, 255, 246), + ) + } + + fn frozen_selection_resize_handle_points( + handle: FrozenSelectionResizeHandleGeometry, + border_outset: f32, + ) -> [Pos2; 3] { + let elbow_offset = border_outset + FROZEN_SELECTION_RESIZE_HANDLE_BORDER_GAP_POINTS; + let arm = FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS; + + match handle.corner { + FrozenSelectionCorner::TopLeft => { + let elbow = + Pos2::new(handle.anchor.x - elbow_offset, handle.anchor.y - elbow_offset); + + [Pos2::new(elbow.x + arm, elbow.y), elbow, Pos2::new(elbow.x, elbow.y + arm)] + }, + FrozenSelectionCorner::TopRight => { + let elbow = + Pos2::new(handle.anchor.x + elbow_offset, handle.anchor.y - elbow_offset); + + [Pos2::new(elbow.x - arm, elbow.y), elbow, Pos2::new(elbow.x, elbow.y + arm)] + }, + FrozenSelectionCorner::BottomLeft => { + let elbow = + Pos2::new(handle.anchor.x - elbow_offset, handle.anchor.y + elbow_offset); + + [Pos2::new(elbow.x + arm, elbow.y), elbow, Pos2::new(elbow.x, elbow.y - arm)] + }, + FrozenSelectionCorner::BottomRight => { + let elbow = + Pos2::new(handle.anchor.x + elbow_offset, handle.anchor.y + elbow_offset); + + [Pos2::new(elbow.x - arm, elbow.y), elbow, Pos2::new(elbow.x, elbow.y - arm)] + }, + } + } + + pub(in crate::overlay) fn render_frozen_selection_resize_handles( + painter: &Painter, + capture_rect: RectPoints, + theme: HudTheme, + ) -> bool { + let border_outset = Self::selection_dashed_border_outset( + FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS, + painter.pixels_per_point(), + ); + let outline_stroke = Self::frozen_selection_resize_handle_outline_stroke(theme); + let stroke = Self::frozen_selection_resize_handle_stroke(theme); + + for handle in Self::frozen_selection_resize_handles(capture_rect) { + let points = Self::frozen_selection_resize_handle_points(handle, border_outset); + painter.add(Shape::line(points.to_vec(), outline_stroke)); + painter.add(Shape::line(points.to_vec(), stroke)); + } + + true + } + pub(in crate::overlay) fn selection_dashed_border_rect( screen_rect: Rect, focus_rect: Rect, @@ -899,21 +1179,86 @@ impl WindowRenderer { segments } + pub(in crate::overlay) fn selection_dashed_border_segments_with_corner_keepout( + rect: Rect, + target_dash_length: f32, + target_gap_length: f32, + corner_keepout: f32, + ) -> Vec<[Pos2; 2]> { + if corner_keepout <= 0.0 { + return Self::selection_dashed_border_segments( + rect, + target_dash_length, + target_gap_length, + ); + } + + let mut segments = Vec::new(); + let horizontal_ranges = Self::selection_dashed_border_edge_dash_ranges( + rect.width(), + corner_keepout, + target_dash_length, + target_gap_length, + ); + let vertical_ranges = Self::selection_dashed_border_edge_dash_ranges( + rect.height(), + corner_keepout, + target_dash_length, + target_gap_length, + ); + + for (start, end) in &horizontal_ranges { + segments.push([ + Pos2::new(rect.min.x + *start, rect.min.y), + Pos2::new(rect.min.x + *end, rect.min.y), + ]); + } + for (start, end) in &vertical_ranges { + segments.push([ + Pos2::new(rect.max.x, rect.min.y + *start), + Pos2::new(rect.max.x, rect.min.y + *end), + ]); + } + for (start, end) in &horizontal_ranges { + segments.push([ + Pos2::new(rect.min.x + *start, rect.max.y), + Pos2::new(rect.min.x + *end, rect.max.y), + ]); + } + for (start, end) in &vertical_ranges { + segments.push([ + Pos2::new(rect.min.x, rect.min.y + *start), + Pos2::new(rect.min.x, rect.min.y + *end), + ]); + } + + segments + } + pub(in crate::overlay) fn selection_dashed_border_cached_segments( selection_dashed_border_cache: &mut SelectionDashedBorderCache, rect: Rect, target_dash_length: f32, target_gap_length: f32, + corner_keepout: f32, ) -> &[[Pos2; 2]] { - let key = SelectionDashedBorderCacheKey::new(rect, target_dash_length, target_gap_length); + let key = SelectionDashedBorderCacheKey::new( + rect, + target_dash_length, + target_gap_length, + corner_keepout, + ); if selection_dashed_border_cache.key != Some(key) { selection_dashed_border_cache.segments.clear(); - selection_dashed_border_cache.segments.extend(Self::selection_dashed_border_segments( - rect, - target_dash_length, - target_gap_length, - )); + selection_dashed_border_cache.segments.extend( + Self::selection_dashed_border_segments_with_corner_keepout( + rect, + target_dash_length, + target_gap_length, + corner_keepout, + ), + ); selection_dashed_border_cache.key = Some(key); } @@ -921,6 +1266,48 @@ impl WindowRenderer { selection_dashed_border_cache.segments.as_slice() } + pub(in crate::overlay) fn selection_dashed_border_edge_dash_ranges( + edge_length: f32, + corner_keepout: f32, + target_dash_length: f32, + target_gap_length: f32, + ) -> Vec<(f32, f32)> { + let usable_length = edge_length - corner_keepout * 2.0; + + if usable_length <= 0.0 { + return Vec::new(); + } + + if usable_length <= target_dash_length { + return vec![(corner_keepout, edge_length - corner_keepout)]; + } + + let dash_length = target_dash_length.min(usable_length); + + let cycle_span = (target_dash_length + target_gap_length).max(f32::MIN_POSITIVE); + let dash_count = + (((usable_length + target_gap_length) / cycle_span).floor() as usize).max(1); + if dash_count == 1 { + return vec![(corner_keepout, edge_length - corner_keepout)]; + } + let occupied_length = dash_count as f32 * dash_length + + dash_count.saturating_sub(1) as f32 * target_gap_length; + let gap_count = dash_count.saturating_sub(1); + let gap_length = if gap_count == 0 { + target_gap_length + } else { + target_gap_length + (usable_length - occupied_length).max(0.0) / gap_count as f32 + }; + + (0..dash_count) + .map(|index| { + let start = corner_keepout + index as f32 * (dash_length + gap_length); + + (start, start + dash_length) + }) + .collect() + } + pub(in crate::overlay) fn selection_dashed_border_dash_ranges( perimeter: f32, target_dash_length: f32, diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 176bd2f3..7fba85a1 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -8,10 +8,10 @@ use std::{ use image::RgbaImage; use crate::overlay::{ - DeviceCursorPointSource, FrozenToolbarTool, GlobalPoint, LIVE_PRESENT_INTERVAL_MIN, - MonitorRect, PhysicalPosition, Pos2, REDRAW_SUBSTEP_CONTRIBUTION_FLOOR, RectPoints, - SLOW_OP_WARN_INTERVAL, ScrollCaptureTraceRecorder, ScrollDirection, ScrollSession, Vec2, - WindowId, + DeviceCursorPointSource, FrozenSelectionInteractionKind, FrozenToolbarTool, GlobalPoint, + LIVE_PRESENT_INTERVAL_MIN, MonitorRect, PhysicalPosition, Pos2, + REDRAW_SUBSTEP_CONTRIBUTION_FLOOR, RectPoints, SLOW_OP_WARN_INTERVAL, + ScrollCaptureTraceRecorder, ScrollDirection, ScrollSession, Vec2, WindowId, }; #[cfg(target_os = "macos")] use crate::overlay::{ExternalScrollInputDrainReader, MacLiveFrameStream}; @@ -168,11 +168,28 @@ impl Default for FrozenToolbarState { } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct FrozenSelectionDragState { pub(super) active: bool, + pub(super) interaction: FrozenSelectionInteractionKind, + pub(super) anchor_rect: RectPoints, pub(super) pointer_offset_x: u32, pub(super) pointer_offset_y: u32, + pub(super) press_cursor_x: u32, + pub(super) press_cursor_y: u32, +} +impl Default for FrozenSelectionDragState { + fn default() -> Self { + Self { + active: false, + interaction: FrozenSelectionInteractionKind::Move, + anchor_rect: RectPoints::new(0, 0, 0, 0), + pointer_offset_x: 0, + pointer_offset_y: 0, + press_cursor_x: 0, + press_cursor_y: 0, + } + } } #[derive(Default)] diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 5c2de2fb..fb935b7d 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -3,6 +3,7 @@ use egui::LayerId; use egui::Order; use egui::Ui; use image::RgbaImage; +use winit::window::CursorIcon; use crate::OverlayControl; #[allow(unused_imports)] @@ -16,8 +17,10 @@ use crate::overlay::tests::{ SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, ScrollSession, SelectionDashedBorderCache, SelectionDashedBorderMetrics, SelectionFlowGeometryCache, SelectionSizeBadgeTarget, TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Vec2, WindowRenderer, - WorkerErrorSource, WorkerResponse, overlay, + overlay, }; +use crate::overlay::{FrozenSelectionCorner, FrozenSelectionInteractionKind}; +use crate::worker::{WorkerErrorSource, WorkerResponse}; #[cfg(target_os = "macos")] #[test] @@ -148,7 +151,15 @@ fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { assert!(session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); assert_eq!( session.frozen_selection_drag, - FrozenSelectionDragState { active: true, pointer_offset_x: 50, pointer_offset_y: 60 } + FrozenSelectionDragState { + active: true, + interaction: FrozenSelectionInteractionKind::Move, + anchor_rect: capture_rect, + pointer_offset_x: 50, + pointer_offset_y: 60, + press_cursor_x: 150, + press_cursor_y: 180, + } ); session.stop_frozen_selection_drag(); @@ -158,6 +169,33 @@ fn frozen_selection_drag_starts_only_for_drag_region_inside_capture_rect() { assert!(!session.begin_frozen_selection_drag(GlobalPoint::new(-1, 180))); } +#[test] +fn frozen_selection_drag_starts_corner_resize_from_handle_hit_zone() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(95, 115))); + assert_eq!( + session.frozen_selection_drag, + FrozenSelectionDragState { + active: true, + interaction: FrozenSelectionInteractionKind::Resize(FrozenSelectionCorner::TopLeft), + anchor_rect: capture_rect, + pointer_offset_x: 0, + pointer_offset_y: 0, + press_cursor_x: 95, + press_cursor_y: 115, + } + ); +} + #[test] fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { let monitor = tests::test_monitor(); @@ -172,8 +210,8 @@ fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { session.seed_frozen_toolbar_default_position(monitor, capture_rect); - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(260, 310))); + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(300, 360))); let expected_rect = RectPoints::new(250, 300, 200, 240); let expected_toolbar_pos = @@ -183,6 +221,49 @@ fn frozen_selection_drag_updates_capture_rect_and_toolbar_position() { assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); } +#[test] +fn frozen_selection_resize_updates_capture_rect_and_toolbar_position() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + session.seed_frozen_toolbar_default_position(monitor, capture_rect); + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(95, 115))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(160, 190))); + + let expected_rect = RectPoints::new(165, 195, 135, 165); + let expected_toolbar_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); + + assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); + assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); +} + +#[test] +fn frozen_selection_resize_preserves_handle_press_offset() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(95, 115))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(96, 116))); + + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(101, 121, 199, 239))); +} + #[test] fn frozen_selection_drag_clamps_capture_rect_to_monitor_bounds() { let monitor = tests::test_monitor(); @@ -195,40 +276,90 @@ fn frozen_selection_drag_clamps_capture_rect_to_monitor_bounds() { session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(110, 130))); + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(150, 180))); assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(-200, -300))); assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(0, 0, 200, 240))); assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1_500, 1_400))); assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(800, 560, 200, 240))); } +#[test] +fn frozen_selection_resize_clamps_to_minimum_size_and_monitor_bounds() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(305, 365))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(-200, -300))); + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(100, 120, 1, 1))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1_500, 1_400))); + assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(100, 120, 900, 680))); +} + +#[test] +fn frozen_selection_rect_update_preserves_manual_toolbar_move() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.seed_frozen_toolbar_default_position(monitor, capture_rect); + + let moved_pos = + session.toolbar_state.floating_position.expect("toolbar default position should be seeded") + + Vec2::new(18.0, 22.0); + + session.toolbar_state.floating_position = Some(moved_pos); + + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(305, 365))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(360, 420))); + + let expected_rect = RectPoints::new(100, 120, 255, 295); + let expected_default_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, expected_rect); + + assert_eq!(session.state.frozen_capture_rect, Some(expected_rect)); + assert_eq!(session.toolbar_state.floating_position, Some(moved_pos)); + assert_eq!(session.toolbar_state.default_slot_position, Some(expected_default_pos)); +} + #[test] fn cropped_frozen_capture_image_uses_moved_capture_rect() { let monitor = MonitorRect { id: 1, origin: GlobalPoint::new(0, 0), - width: 4, - height: 3, + width: 64, + height: 48, scale_factor_x1000: 1_000, }; - let image = RgbaImage::from_fn(4, 3, |x, y| Rgba([x as u8, y as u8, 0, 255])); + let image = RgbaImage::from_fn(64, 48, |x, y| Rgba([x as u8, y as u8, 0, 255])); let mut session = OverlaySession::new(); session.state.begin_freeze(monitor); session.state.finish_freeze(monitor, image); - session.state.frozen_capture_rect = Some(RectPoints::new(0, 0, 2, 1)); + session.state.frozen_capture_rect = Some(RectPoints::new(8, 6, 40, 32)); session.frozen_capture_source = FrozenCaptureSource::DragRegion; - assert!(session.begin_frozen_selection_drag(GlobalPoint::new(0, 0))); - assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(1, 1))); + assert!(session.begin_frozen_selection_drag(GlobalPoint::new(28, 22))); + assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(32, 28))); let cropped = session.cropped_frozen_capture_image().expect("moved frozen crop"); - assert_eq!(cropped.width(), 2); - assert_eq!(cropped.height(), 1); - assert_eq!(cropped.get_pixel(0, 0), &Rgba([1, 1, 0, 255])); - assert_eq!(cropped.get_pixel(1, 0), &Rgba([2, 1, 0, 255])); + assert_eq!(cropped.width(), 40); + assert_eq!(cropped.height(), 32); + assert_eq!(cropped.get_pixel(0, 0), &Rgba([12, 12, 0, 255])); + assert_eq!(cropped.get_pixel(39, 31), &Rgba([51, 43, 0, 255])); } #[test] @@ -262,6 +393,76 @@ fn auto_center_frozen_capture_rect_recenters_detected_content() { assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); } +#[test] +fn frozen_selection_resize_hit_test_prefers_corner_handles() { + let capture_rect = RectPoints::new(100, 120, 8, 8); + + assert_eq!( + WindowRenderer::frozen_selection_resize_hit_test(capture_rect, Pos2::new(100.0, 120.0)), + Some(FrozenSelectionCorner::TopLeft) + ); + assert_eq!( + WindowRenderer::frozen_selection_resize_hit_test(capture_rect, Pos2::new(108.0, 128.0)), + Some(FrozenSelectionCorner::BottomRight) + ); + assert_eq!( + WindowRenderer::frozen_selection_resize_hit_test(capture_rect, Pos2::new(104.0, 124.0)), + None + ); +} + +#[test] +fn frozen_selection_interaction_keeps_move_in_tiny_selection_center() { + let capture_rect = RectPoints::new(100, 120, 8, 8); + + assert_eq!( + OverlaySession::frozen_selection_interaction_kind(capture_rect, 104, 124), + Some(FrozenSelectionInteractionKind::Move) + ); +} + +#[test] +fn frozen_selection_cursor_icon_uses_corner_resize_hover() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.state.cursor = Some(GlobalPoint::new(95, 115)); + + assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::NwResize); + + session.state.cursor = Some(GlobalPoint::new(150, 180)); + + assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::Default); +} + +#[test] +fn frozen_selection_cursor_icon_tracks_active_resize_drag() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 200, 240); + let mut session = OverlaySession::new(); + + session.state.begin_freeze(monitor); + session.state.finish_freeze(monitor, tests::test_frozen_image()); + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.frozen_selection_drag = FrozenSelectionDragState { + active: true, + interaction: FrozenSelectionInteractionKind::Resize(FrozenSelectionCorner::BottomRight), + anchor_rect: capture_rect, + pointer_offset_x: 0, + pointer_offset_y: 0, + press_cursor_x: 300, + press_cursor_y: 360, + }; + + assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::SeResize); +} + #[test] fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { let monitor = tests::test_monitor_with_scale(400, 300, 2_000); @@ -296,8 +497,15 @@ fn auto_center_frozen_capture_rect_noops_for_uniform_crop() { fn global_left_release_stops_frozen_selection_drag() { let mut session = OverlaySession::new(); - session.frozen_selection_drag = - FrozenSelectionDragState { active: true, pointer_offset_x: 12, pointer_offset_y: 34 }; + session.frozen_selection_drag = FrozenSelectionDragState { + active: true, + interaction: FrozenSelectionInteractionKind::Move, + anchor_rect: RectPoints::new(10, 20, 30, 40), + pointer_offset_x: 12, + pointer_offset_y: 34, + press_cursor_x: 22, + press_cursor_y: 54, + }; session .maybe_stop_frozen_selection_drag_for_mouse_input(ElementState::Pressed, MouseButton::Left); @@ -404,7 +612,7 @@ fn selection_dashed_border_rect_expands_focus_rect_outward() { assert_eq!( WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), - Some(Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5),)) + Some(focus_rect.expand(border_outset)) ); } @@ -417,7 +625,7 @@ fn selection_dashed_border_rect_can_extend_beyond_screen_edge() { assert_eq!( WindowRenderer::selection_dashed_border_rect(screen_rect, focus_rect, border_outset,), - Some(Rect::from_min_max(Pos2::new(-1.5, 8.5), Pos2::new(41.5, 41.5),)) + Some(focus_rect.expand(border_outset)) ); } @@ -433,7 +641,12 @@ fn selection_dashed_border_dash_ranges_distribute_remainder_evenly() { SELECTION_DASHED_BORDER_GAP_LENGTH_PX, ); - assert_eq!(ranges.len(), 15); + let expected_cycle_count = (perimeter + / (SELECTION_DASHED_BORDER_DASH_LENGTH_PX + SELECTION_DASHED_BORDER_GAP_LENGTH_PX)) + .round() + .max(1.0) as usize; + + assert_eq!(ranges.len(), expected_cycle_count); let dash_length = ranges[0].1 - ranges[0].0; let gap_length = ranges[1].0 - ranges[0].1; @@ -479,6 +692,7 @@ fn selection_dashed_border_cache_reuses_geometry_for_same_rect() { rect, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + 0.0, ) .to_vec(); @@ -491,6 +705,7 @@ fn selection_dashed_border_cache_reuses_geometry_for_same_rect() { rect, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + 0.0, ); assert_eq!(cached[0], sentinel); @@ -500,20 +715,91 @@ fn selection_dashed_border_cache_reuses_geometry_for_same_rect() { other_rect, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + 0.0, ); assert_ne!(rebuilt[0], sentinel); } +#[test] +fn selection_dashed_border_cache_rebuilds_when_corner_keepout_changes() { + let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); + let sentinel = [Pos2::new(-1.0, -1.0), Pos2::new(-2.0, -2.0)]; + let mut cache = SelectionDashedBorderCache::default(); + + let initial = WindowRenderer::selection_dashed_border_cached_segments( + &mut cache, + rect, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + 0.0, + ) + .to_vec(); + + assert!(!initial.is_empty()); + + cache.segments[0] = sentinel; + + let rebuilt = WindowRenderer::selection_dashed_border_cached_segments( + &mut cache, + rect, + SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + 8.0, + ); + + assert_ne!(rebuilt[0], sentinel); +} + +#[test] +fn selection_dashed_border_segments_with_corner_keepout_pin_edge_clearance() { + const EPSILON: f32 = 1e-4; + + let rect = Rect::from_min_max(Pos2::new(10.0, 20.0), Pos2::new(70.0, 52.0)); + let keepout = 10.0; + let dash = 8.0; + let gap = 4.0; + let segments = WindowRenderer::selection_dashed_border_segments_with_corner_keepout( + rect, dash, gap, keepout, + ); + let top_segments: Vec<_> = segments + .iter() + .copied() + .filter(|segment| segment[0].y == rect.min.y && segment[1].y == rect.min.y) + .collect(); + + assert!(!top_segments.is_empty()); + + let first = top_segments.first().unwrap(); + let last = top_segments.last().unwrap(); + let left_padding = first[0].x - rect.min.x - keepout; + let right_padding = rect.max.x - keepout - last[1].x; + + assert!(left_padding.abs() < EPSILON); + assert!(right_padding.abs() < EPSILON); +} + +#[test] +fn selection_dashed_border_edge_dash_ranges_expand_single_dash_to_keepouts() { + assert_eq!( + WindowRenderer::selection_dashed_border_edge_dash_ranges(28.0, 10.0, 12.0, 4.0), + vec![(10.0, 18.0)] + ); + assert_eq!( + WindowRenderer::selection_dashed_border_edge_dash_ranges(34.0, 10.0, 12.0, 4.0), + vec![(10.0, 24.0)] + ); +} + #[test] fn selection_dashed_border_outset_accounts_for_feathering() { assert_eq!( WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 1.0), - 1.5 + (SELECTION_DASHED_BORDER_WIDTH_PX + 1.0) * 0.5 ); assert_eq!( WindowRenderer::selection_dashed_border_outset(SELECTION_DASHED_BORDER_WIDTH_PX, 2.0), - 1.25 + (SELECTION_DASHED_BORDER_WIDTH_PX + 0.5) * 0.5 ); } @@ -521,18 +807,26 @@ fn selection_dashed_border_outset_accounts_for_feathering() { fn selection_dashed_border_metrics_track_physical_pixels() { assert_eq!( WindowRenderer::selection_dashed_border_metrics(1.0), - SelectionDashedBorderMetrics { stroke_width: 2.0, dash_length: 6.0, gap_length: 4.0 } + SelectionDashedBorderMetrics { + stroke_width: SELECTION_DASHED_BORDER_WIDTH_PX, + dash_length: SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + gap_length: SELECTION_DASHED_BORDER_GAP_LENGTH_PX, + } ); assert_eq!( WindowRenderer::selection_dashed_border_metrics(2.0), - SelectionDashedBorderMetrics { stroke_width: 1.0, dash_length: 3.0, gap_length: 2.0 } + SelectionDashedBorderMetrics { + stroke_width: SELECTION_DASHED_BORDER_WIDTH_PX / 2.0, + dash_length: SELECTION_DASHED_BORDER_DASH_LENGTH_PX / 2.0, + gap_length: SELECTION_DASHED_BORDER_GAP_LENGTH_PX / 2.0, + } ); assert_eq!( WindowRenderer::selection_dashed_border_metrics(1.5), SelectionDashedBorderMetrics { - stroke_width: 2.0 / 1.5, - dash_length: 6.0 / 1.5, - gap_length: 4.0 / 1.5, + stroke_width: SELECTION_DASHED_BORDER_WIDTH_PX / 1.5, + dash_length: SELECTION_DASHED_BORDER_DASH_LENGTH_PX / 1.5, + gap_length: SELECTION_DASHED_BORDER_GAP_LENGTH_PX / 1.5, } ); } @@ -1440,6 +1734,7 @@ fn render_frozen_capture_affordance_keeps_tiny_frozen_badge_path() { monitor, screen_rect, HudTheme::Dark, + false, FrozenCaptureSource::None, None, false, @@ -1458,7 +1753,7 @@ fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { let monitor = tests::test_monitor(); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - let selection_dashed_border_cache = SelectionDashedBorderCache::default(); + let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); let mut state = OverlayState::new(); let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); @@ -1478,10 +1773,44 @@ fn render_live_capture_affordances_keep_hover_scrim_when_flow_disabled() { false, 1.0, &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, )); assert_eq!(selection_dashed_border_cache.key, None); } +#[test] +fn render_live_capture_affordances_draw_drag_border_when_flow_disabled() { + let ctx = tests::test_egui_context(); + let layer = LayerId::new(Order::Foreground, Id::new("live-drag-flow-disabled")); + let painter = ctx.layer_painter(layer); + let monitor = tests::test_monitor(); + let screen_rect = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + let mut selection_dashed_border_cache = SelectionDashedBorderCache::default(); + let mut state = OverlayState::new(); + let mut selection_flow_geometry_cache = SelectionFlowGeometryCache::default(); + + state.mode = OverlayMode::Live; + state.drag_rect = Some(MonitorRectPoints { + monitor_id: monitor.id, + rect: RectPoints::new(100, 120, 240, 320), + }); + + assert!(WindowRenderer::render_live_capture_affordances( + &ctx, + &painter, + &state, + monitor, + screen_rect, + HudTheme::Light, + false, + 1.0, + &mut selection_flow_geometry_cache, + &mut selection_dashed_border_cache, + )); + assert!(selection_dashed_border_cache.key.is_some()); +} + #[test] fn live_capture_size_badge_target_keeps_tiny_drag_rect() { let monitor = tests::test_monitor(); diff --git a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs index 07e8ea16..cf023543 100644 --- a/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/toolbar_runtime.rs @@ -250,6 +250,7 @@ impl OverlaySession { self.config.selection_flow_stroke_width_px, false, false, + false, self.frozen_capture_source, self.frozen_capture_source == FrozenCaptureSource::FullscreenFallback, None, From 3af716a1df9dd360873afd04fd915d1266178173 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sun, 5 Apr 2026 12:34:21 +0800 Subject: [PATCH 2/2] {"schema":"delivery/1","type":"fix","scope":"rsnap-overlay","summary":"repair lint and resize review follow-up","intent":"clear the post-rebase CI regression and finish the corner-resize review repair on the PR head without changing the shipped feature scope","impact":"corner resize now preserves press offset from the enlarged handle hit zone, Linux test imports no longer trip clippy, and the affected overlay files satisfy vstyle and regression checks","breaking":false,"risk":"low","authority":"review","delivery_mode":"status-only","refs":[]} --- packages/rsnap-overlay/src/overlay.rs | 46 +++++++++++-------- .../src/overlay/rendering/affordances.rs | 11 +++-- packages/rsnap-overlay/src/overlay/tests.rs | 1 + .../src/overlay/tests/rendering_behaviors.rs | 6 +-- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 05f11361..e3b744ba 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -1702,10 +1702,10 @@ impl OverlaySession { let Some((target_monitor, capture_rect)) = self.frozen_selection_drag_target() else { return CursorIcon::Default; }; + if target_monitor != monitor { return CursorIcon::Default; } - if self.frozen_selection_drag.active { return match self.frozen_selection_drag.interaction { FrozenSelectionInteractionKind::Resize(corner) => { @@ -1778,6 +1778,7 @@ impl OverlaySession { }, FrozenSelectionInteractionKind::Resize(corner) => { let (cursor_x, cursor_y) = Self::local_point_in_monitor_space(monitor, global); + Self::resize_frozen_capture_rect_from_corner( monitor, anchor_rect, @@ -3309,23 +3310,7 @@ impl OverlaySession { }; if matches!(self.state.mode, OverlayMode::Frozen) { - self.reset_toolbar_pointer_state(); - - match state { - ElementState::Pressed => { - let cursor = self.current_device_cursor(); - let _ = self.begin_frozen_selection_drag(cursor); - self.sync_overlay_cursor_icons(); - }, - ElementState::Released => { - self.stop_frozen_selection_drag(); - self.sync_overlay_cursor_icons(); - }, - } - - self.request_redraw_for_monitor(monitor); - - return OverlayControl::Continue; + return self.handle_frozen_left_mouse_input(monitor, state); } if !matches!(self.state.mode, OverlayMode::Live) { return OverlayControl::Continue; @@ -3421,6 +3406,31 @@ impl OverlaySession { } } + fn handle_frozen_left_mouse_input( + &mut self, + monitor: MonitorRect, + state: ElementState, + ) -> OverlayControl { + self.reset_toolbar_pointer_state(); + + match state { + ElementState::Pressed => { + let cursor = self.current_device_cursor(); + let _ = self.begin_frozen_selection_drag(cursor); + + self.sync_overlay_cursor_icons(); + }, + ElementState::Released => { + self.stop_frozen_selection_drag(); + self.sync_overlay_cursor_icons(); + }, + } + + self.request_redraw_for_monitor(monitor); + + OverlayControl::Continue + } + fn handle_scroll_mouse_wheel( &mut self, window_id: WindowId, diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index d8899633..dd79ef93 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -201,6 +201,7 @@ impl WindowRenderer { has_affordance = true; } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { has_affordance |= Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); @@ -232,6 +233,7 @@ impl WindowRenderer { has_affordance = true; } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { has_affordance |= Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); @@ -266,6 +268,7 @@ impl WindowRenderer { theme, ); } + if show_resize_handles && let Some(capture_rect) = state.frozen_capture_rect { Self::render_frozen_selection_resize_handles(&painter, capture_rect, theme); } @@ -989,6 +992,7 @@ impl WindowRenderer { if let Some(stroke_width) = stroke_width_override { metrics.stroke_width = stroke_width; } + let border_outset = Self::selection_dashed_border_outset(metrics.stroke_width, painter.pixels_per_point()); let Some(border_rect) = @@ -1114,6 +1118,7 @@ impl WindowRenderer { for handle in Self::frozen_selection_resize_handles(capture_rect) { let points = Self::frozen_selection_resize_handle_points(handle, border_outset); + painter.add(Shape::line(points.to_vec(), outline_stroke)); painter.add(Shape::line(points.to_vec(), stroke)); } @@ -1193,7 +1198,6 @@ impl WindowRenderer { ); } - let mut segments = Vec::new(); let horizontal_ranges = Self::selection_dashed_border_edge_dash_ranges( rect.width(), corner_keepout, @@ -1206,6 +1210,7 @@ impl WindowRenderer { target_dash_length, target_gap_length, ); + let mut segments = Vec::new(); for (start, end) in &horizontal_ranges { segments.push([ @@ -1277,19 +1282,19 @@ impl WindowRenderer { if usable_length <= 0.0 { return Vec::new(); } - if usable_length <= target_dash_length { return vec![(corner_keepout, edge_length - corner_keepout)]; } let dash_length = target_dash_length.min(usable_length); - let cycle_span = (target_dash_length + target_gap_length).max(f32::MIN_POSITIVE); let dash_count = (((usable_length + target_gap_length) / cycle_span).floor() as usize).max(1); + if dash_count == 1 { return vec![(corner_keepout, edge_length - corner_keepout)]; } + let occupied_length = dash_count as f32 * dash_length + dash_count.saturating_sub(1) as f32 * target_gap_length; let gap_count = dash_count.saturating_sub(1); diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index 308535c7..f9732e4b 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -79,6 +79,7 @@ use crate::state::{ use crate::state::{WindowListSnapshot, WindowRect}; #[cfg(target_os = "macos")] use crate::worker::OverlayWorker; +#[cfg(target_os = "macos")] use crate::worker::{WorkerErrorSource, WorkerResponse}; #[cfg(target_os = "macos")] diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index fb935b7d..5d525422 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -260,7 +260,6 @@ fn frozen_selection_resize_preserves_handle_press_offset() { assert!(session.begin_frozen_selection_drag(GlobalPoint::new(95, 115))); assert!(session.update_frozen_selection_drag_rect(GlobalPoint::new(96, 116))); - assert_eq!(session.state.frozen_capture_rect, Some(RectPoints::new(101, 121, 199, 239))); } @@ -313,6 +312,7 @@ fn frozen_selection_rect_update_preserves_manual_toolbar_move() { session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.seed_frozen_toolbar_default_position(monitor, capture_rect); let moved_pos = @@ -429,6 +429,7 @@ fn frozen_selection_cursor_icon_uses_corner_resize_hover() { session.state.begin_freeze(monitor); session.state.finish_freeze(monitor, tests::test_frozen_image()); + session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; session.state.cursor = Some(GlobalPoint::new(95, 115)); @@ -448,6 +449,7 @@ fn frozen_selection_cursor_icon_tracks_active_resize_drag() { session.state.begin_freeze(monitor); session.state.finish_freeze(monitor, tests::test_frozen_image()); + session.state.frozen_capture_rect = Some(capture_rect); session.frozen_capture_source = FrozenCaptureSource::DragRegion; session.frozen_selection_drag = FrozenSelectionDragState { @@ -640,7 +642,6 @@ fn selection_dashed_border_dash_ranges_distribute_remainder_evenly() { SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, ); - let expected_cycle_count = (perimeter / (SELECTION_DASHED_BORDER_DASH_LENGTH_PX + SELECTION_DASHED_BORDER_GAP_LENGTH_PX)) .round() @@ -726,7 +727,6 @@ fn selection_dashed_border_cache_rebuilds_when_corner_keepout_changes() { let rect = Rect::from_min_max(Pos2::new(18.5, 8.5), Pos2::new(61.5, 41.5)); let sentinel = [Pos2::new(-1.0, -1.0), Pos2::new(-2.0, -2.0)]; let mut cache = SelectionDashedBorderCache::default(); - let initial = WindowRenderer::selection_dashed_border_cached_segments( &mut cache, rect,