From 2164845ad96fbdd78bd1a7dd20709ba8414eaaf4 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 01:33:45 +0800 Subject: [PATCH 1/8] {"schema":"maestro/commit/1","summary":"refresh frozen handle styling and macos hover cursors","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 536 +++++++++++++++++- .../src/overlay/cursor_context_runtime.rs | 86 ++- .../rsnap-overlay/src/overlay/rendering.rs | 4 + .../src/overlay/rendering/affordances.rs | 76 +-- .../src/overlay/tests/live_runtime.rs | 34 ++ .../src/overlay/tests/rendering_behaviors.rs | 73 ++- .../src/overlay/window_runtime.rs | 11 +- 7 files changed, 753 insertions(+), 67 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index da57164b..99a6140d 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -34,7 +34,7 @@ use std::{ cmp::Ordering, collections::{HashMap, HashSet}, path::PathBuf, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, time::{Duration, Instant}, }; @@ -64,12 +64,18 @@ use image::{ imageops::{self, FilterType}, }; #[cfg(target_os = "macos")] -use objc::runtime::{Object, YES}; +use objc::declare::ClassDecl; +#[cfg(target_os = "macos")] +use objc::runtime::{BOOL, Class, Object, YES}; +#[cfg(target_os = "macos")] +use objc::{Encode, Encoding}; #[cfg(target_os = "macos")] use objc2::MainThreadMarker; #[cfg(target_os = "macos")] use objc2_app_kit::NSScreen; #[cfg(target_os = "macos")] +use objc2_foundation::{NSPoint, NSRect, NSSize}; +#[cfg(target_os = "macos")] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use serde::{Deserialize, Serialize}; use wgpu::Adapter; @@ -325,6 +331,7 @@ 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 FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX: f32 = 1.55; 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; @@ -332,9 +339,10 @@ 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 FROZEN_SELECTION_RESIZE_HANDLE_CORNER_KEEPOUT_POINTS: f32 = 4.25; +const FROZEN_SELECTION_RESIZE_HANDLE_OUTER_RADIUS_POINTS: f32 = 4.25; +const FROZEN_SELECTION_RESIZE_HANDLE_CENTER_DOT_RADIUS_POINTS: f32 = 1.15; +const FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS: f32 = 1.3; 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; @@ -656,6 +664,66 @@ enum AcquiredSurfaceFrame { Skipped(SurfaceFrameSkipReason), } +#[derive(Clone, Copy, Debug, PartialEq)] +struct OverlayCursorRect { + rect: Rect, + icon: CursorIcon, +} +impl OverlayCursorRect { + const fn new(rect: Rect, icon: CursorIcon) -> Self { + Self { rect, icon } + } +} + +fn overlay_cursor_rect_icon_at_point( + rects: &[OverlayCursorRect], + point: Pos2, +) -> Option { + rects.iter().find(|entry| entry.rect.contains(point)).map(|entry| entry.icon) +} + +fn sort_unique_axis_values(values: &mut Vec) { + values.sort_by(f32::total_cmp); + values.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON); +} + +#[cfg(target_os = "macos")] +pub(super) struct MacOSOverlayCursorRectSupport { + view_key: usize, +} +#[cfg(target_os = "macos")] +impl MacOSOverlayCursorRectSupport { + const fn new(view_key: usize) -> Self { + Self { view_key } + } + + fn sync_cursor_rects(&self, window: &Window, rects: &[OverlayCursorRect]) { + macos_resize_overlay_cursor_view(window, self.view_key); + if macos_set_overlay_view_cursor_rects(self.view_key, rects) { + macos_invalidate_overlay_cursor_rects(self.view_key); + } + macos_apply_overlay_cursor_for_current_pointer(self.view_key); + } +} + +#[cfg(target_os = "macos")] +impl Drop for MacOSOverlayCursorRectSupport { + fn drop(&mut self) { + let rects = macos_overlay_view_cursor_rects(); + + match rects.lock() { + Ok(mut guard) => { + guard.remove(&self.view_key); + }, + Err(poisoned) => { + poisoned.into_inner().remove(&self.view_key); + }, + } + + macos_remove_overlay_cursor_view(self.view_key); + } +} + #[derive(Clone, Debug)] /// Runtime configuration applied to a capture overlay session. pub struct OverlayConfig { @@ -1696,13 +1764,16 @@ impl OverlaySession { 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, + FrozenSelectionCorner::TopLeft | FrozenSelectionCorner::BottomRight => { + CursorIcon::NwseResize + }, + FrozenSelectionCorner::TopRight | FrozenSelectionCorner::BottomLeft => { + CursorIcon::NeswResize + }, } } + #[cfg_attr(target_os = "macos", allow(dead_code))] 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; @@ -1735,6 +1806,7 @@ impl OverlaySession { } } + #[cfg_attr(target_os = "macos", allow(dead_code))] fn overlay_cursor_icon_for_monitor(&self, monitor: MonitorRect) -> CursorIcon { match self.state.mode { OverlayMode::Frozen => self.frozen_selection_cursor_icon_for_monitor(monitor), @@ -1742,11 +1814,125 @@ impl OverlaySession { } } + fn frozen_selection_cursor_rects_for_monitor( + &self, + monitor: MonitorRect, + ) -> Vec { + if !matches!(self.state.mode, OverlayMode::Frozen) { + return Vec::new(); + } + + let Some((target_monitor, capture_rect)) = self.frozen_selection_drag_target() else { + return Vec::new(); + }; + + if target_monitor != monitor { + return Vec::new(); + } + + let overlay_bounds = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + + if self.frozen_selection_drag.active { + return match self.frozen_selection_drag.interaction { + FrozenSelectionInteractionKind::Resize(corner) => vec![OverlayCursorRect::new( + overlay_bounds, + Self::frozen_selection_resize_cursor_icon(corner), + )], + FrozenSelectionInteractionKind::Move => Vec::new(), + }; + } + + Self::frozen_selection_hover_cursor_rects(capture_rect) + .into_iter() + .filter_map(|cursor_rect| { + let rect = cursor_rect.rect.intersect(overlay_bounds); + + (rect.width() > 0.0 && rect.height() > 0.0) + .then_some(OverlayCursorRect::new(rect, cursor_rect.icon)) + }) + .collect() + } + + fn frozen_selection_hover_cursor_rects(capture_rect: RectPoints) -> Vec { + let selection_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 interior_reach_x = (selection_rect.width() * 0.35) + .min(FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS); + let interior_reach_y = (selection_rect.height() * 0.35) + .min(FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS); + let mut x_edges = vec![ + selection_rect.min.x, + selection_rect.min.x + interior_reach_x, + selection_rect.center().x, + selection_rect.max.x - interior_reach_x, + selection_rect.max.x, + ]; + let mut y_edges = vec![ + selection_rect.min.y, + selection_rect.min.y + interior_reach_y, + selection_rect.center().y, + selection_rect.max.y - interior_reach_y, + selection_rect.max.y, + ]; + + for handle in WindowRenderer::frozen_selection_resize_handles(capture_rect) { + x_edges.push(handle.hit_rect.min.x); + x_edges.push(handle.hit_rect.max.x); + y_edges.push(handle.hit_rect.min.y); + y_edges.push(handle.hit_rect.max.y); + } + + sort_unique_axis_values(&mut x_edges); + sort_unique_axis_values(&mut y_edges); + + let mut rects = Vec::new(); + + for x_pair in x_edges.windows(2) { + let [min_x, max_x] = [x_pair[0], x_pair[1]]; + if max_x <= min_x { + continue; + } + + for y_pair in y_edges.windows(2) { + let [min_y, max_y] = [y_pair[0], y_pair[1]]; + if max_y <= min_y { + continue; + } + + let rect = Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y)); + let point = rect.center(); + let Some(corner) = + WindowRenderer::frozen_selection_resize_hit_test(capture_rect, point) + else { + continue; + }; + + rects.push(OverlayCursorRect::new( + rect, + Self::frozen_selection_resize_cursor_icon(corner), + )); + } + } + + rects + } + 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)); + #[cfg(not(target_os = "macos"))] + let icon = self.overlay_cursor_icon_for_monitor(overlay_window.monitor); + + #[cfg(not(target_os = "macos"))] + overlay_window.window.set_cursor(icon); + + #[cfg(target_os = "macos")] + overlay_window.cursor_rects.sync_cursor_rects( + overlay_window.window.as_ref(), + &self.frozen_selection_cursor_rects_for_monitor(overlay_window.monitor), + ); } } @@ -3155,6 +3341,19 @@ impl OverlaySession { self.sample_mouse_location() } + fn current_frozen_interaction_cursor(&mut self) -> GlobalPoint { + if let Some((_, cursor)) = self.last_fresh_event_cursor() { + return cursor; + } + if let Some(cursor) = self.state.cursor { + return cursor; + } + + let raw = self.current_device_cursor(); + + self.resolve_device_cursor_point(raw).map(|(_, cursor, _)| cursor).unwrap_or(raw) + } + fn trace_cursor_moved_with_mapping(&self, trace: CursorMoveTrace) { if !tracing::enabled!(tracing::Level::TRACE) { return; @@ -3363,7 +3562,7 @@ impl OverlaySession { match state { ElementState::Pressed => { - let cursor = self.current_device_cursor(); + let cursor = self.current_frozen_interaction_cursor(); let _ = self.begin_frozen_selection_drag(cursor); self.sync_overlay_cursor_icons(); @@ -5586,6 +5785,317 @@ fn macos_mouse_location() -> Option { Some(GlobalPoint::new(point.x as i32, point.y as i32)) } +#[cfg(target_os = "macos")] +fn macos_overlay_view_cursor_rects() -> &'static Mutex>> { + static RECTS: OnceLock>>> = OnceLock::new(); + + RECTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(target_os = "macos")] +fn macos_set_overlay_view_cursor_rects(view_key: usize, rects: &[OverlayCursorRect]) -> bool { + let rects_by_view = macos_overlay_view_cursor_rects(); + + match rects_by_view.lock() { + Ok(mut guard) => { + let unchanged = + guard.get(&view_key).is_some_and(|existing| existing.as_slice() == rects); + if unchanged || (rects.is_empty() && !guard.contains_key(&view_key)) { + return false; + } + + if rects.is_empty() { + guard.remove(&view_key); + } else { + guard.insert(view_key, rects.to_vec()); + } + }, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + let unchanged = + guard.get(&view_key).is_some_and(|existing| existing.as_slice() == rects); + if unchanged || (rects.is_empty() && !guard.contains_key(&view_key)) { + return false; + } + + if rects.is_empty() { + guard.remove(&view_key); + } else { + guard.insert(view_key, rects.to_vec()); + } + }, + } + + true +} + +#[cfg(target_os = "macos")] +fn macos_overlay_view_cursor_rect_entries(view_key: usize) -> Option> { + let rects = macos_overlay_view_cursor_rects(); + + match rects.lock() { + Ok(guard) => guard.get(&view_key).cloned(), + Err(poisoned) => poisoned.into_inner().get(&view_key).cloned(), + } +} + +#[cfg(target_os = "macos")] +fn macos_cursor_object_for_icon(icon: CursorIcon) -> *mut Object { + let cursor_class = objc::class!(NSCursor); + + match icon { + CursorIcon::Grab => unsafe { objc::msg_send![cursor_class, openHandCursor] }, + CursorIcon::Grabbing => unsafe { objc::msg_send![cursor_class, closedHandCursor] }, + CursorIcon::NeswResize => unsafe { + let responds: bool = objc::msg_send![cursor_class, respondsToSelector: objc::sel!(_windowResizeNorthEastSouthWestCursor)]; + if responds { + objc::msg_send![cursor_class, performSelector: objc::sel!(_windowResizeNorthEastSouthWestCursor)] + } else { + objc::msg_send![cursor_class, arrowCursor] + } + }, + CursorIcon::NwseResize => unsafe { + let responds: bool = objc::msg_send![cursor_class, respondsToSelector: objc::sel!(_windowResizeNorthWestSouthEastCursor)]; + if responds { + objc::msg_send![cursor_class, performSelector: objc::sel!(_windowResizeNorthWestSouthEastCursor)] + } else { + objc::msg_send![cursor_class, arrowCursor] + } + }, + _ => unsafe { objc::msg_send![cursor_class, arrowCursor] }, + } +} + +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Clone, Copy)] +struct MacOSOverlayPoint { + x: f64, + y: f64, +} + +#[cfg(target_os = "macos")] +unsafe impl Encode for MacOSOverlayPoint { + fn encode() -> Encoding { + unsafe { Encoding::from_str("{CGPoint=dd}") } + } +} + +#[cfg(target_os = "macos")] +extern "C" fn macos_overlay_cursor_view_is_flipped( + _this: &Object, + _cmd: objc::runtime::Sel, +) -> BOOL { + let _ = _cmd; + YES +} + +#[cfg(target_os = "macos")] +extern "C" fn macos_overlay_cursor_view_hit_test( + _this: &Object, + _cmd: objc::runtime::Sel, + _point: MacOSOverlayPoint, +) -> *mut Object { + let _ = (_cmd, _point); + ptr::null_mut() +} + +#[cfg(target_os = "macos")] +extern "C" fn macos_overlay_cursor_view_reset_cursor_rects( + this: &Object, + _cmd: objc::runtime::Sel, +) { + let _ = _cmd; + let view_key = (this as *const Object) as usize; + let Some(entries) = macos_overlay_view_cursor_rect_entries(view_key) else { + return; + }; + + for entry in entries { + let cursor = macos_cursor_object_for_icon(entry.icon); + if cursor.is_null() { + continue; + } + + let rect = NSRect::new( + NSPoint::new(f64::from(entry.rect.min.x), f64::from(entry.rect.min.y)), + NSSize::new(f64::from(entry.rect.width()), f64::from(entry.rect.height())), + ); + + unsafe { + let _: () = objc::msg_send![this, addCursorRect: rect cursor: cursor]; + } + } +} + +#[cfg(target_os = "macos")] +fn macos_overlay_cursor_view_class() -> *const Class { + static CLASS: OnceLock = OnceLock::new(); + + (*CLASS.get_or_init(|| { + if let Some(class) = Class::get("RsnapOverlayCursorView") { + return class as *const Class as usize; + } + + let superclass = objc::class!(NSView); + let mut decl = ClassDecl::new("RsnapOverlayCursorView", superclass) + .expect("cursor overlay view class"); + + unsafe { + decl.add_method( + objc::sel!(isFlipped), + macos_overlay_cursor_view_is_flipped + as extern "C" fn(&Object, objc::runtime::Sel) -> BOOL, + ); + decl.add_method( + objc::sel!(hitTest:), + macos_overlay_cursor_view_hit_test + as extern "C" fn(&Object, objc::runtime::Sel, MacOSOverlayPoint) -> *mut Object, + ); + decl.add_method( + objc::sel!(resetCursorRects), + macos_overlay_cursor_view_reset_cursor_rects + as extern "C" fn(&Object, objc::runtime::Sel), + ); + } + + decl.register() as *const Class as usize + })) as *const Class +} + +#[cfg(target_os = "macos")] +fn macos_overlay_window_ns_view(window: &Window) -> Option<*mut Object> { + let Ok(handle) = window.window_handle() else { + return None; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return None; + }; + + Some(appkit.ns_view.as_ptr().cast::()) +} + +#[cfg(target_os = "macos")] +fn macos_resize_overlay_cursor_view(window: &Window, overlay_view_key: usize) { + let Some(ns_view) = macos_overlay_window_ns_view(window) else { + return; + }; + let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { + return; + } + + let bounds: NSRect = unsafe { objc::msg_send![ns_view, bounds] }; + + unsafe { + let _: () = objc::msg_send![overlay_view, setFrame: bounds]; + } +} + +#[cfg(target_os = "macos")] +fn macos_invalidate_overlay_cursor_rects(overlay_view_key: usize) { + let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { + return; + } + + unsafe { + let ns_window: *mut Object = objc::msg_send![overlay_view, window]; + if ns_window.is_null() { + return; + } + + let _: () = objc::msg_send![ns_window, invalidateCursorRectsForView: overlay_view]; + } +} + +#[cfg(target_os = "macos")] +fn macos_overlay_view_current_local_point(overlay_view_key: usize) -> Option { + let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { + return None; + } + + unsafe { + let ns_window: *mut Object = objc::msg_send![overlay_view, window]; + if ns_window.is_null() { + return None; + } + + let window_point: NSPoint = objc::msg_send![ns_window, mouseLocationOutsideOfEventStream]; + let local_point: NSPoint = objc::msg_send![overlay_view, convertPoint: window_point fromView: ptr::null_mut::()]; + + Some(Pos2::new(local_point.x as f32, local_point.y as f32)) + } +} + +#[cfg(target_os = "macos")] +fn macos_apply_overlay_cursor_for_current_pointer(overlay_view_key: usize) { + let Some(entries) = macos_overlay_view_cursor_rect_entries(overlay_view_key) else { + return; + }; + let Some(local_point) = macos_overlay_view_current_local_point(overlay_view_key) else { + return; + }; + + let icon = + overlay_cursor_rect_icon_at_point(&entries, local_point).unwrap_or(CursorIcon::Default); + let cursor = macos_cursor_object_for_icon(icon); + if cursor.is_null() { + return; + } + + unsafe { + let _: () = objc::msg_send![cursor, set]; + } +} + +#[cfg(target_os = "macos")] +fn macos_remove_overlay_cursor_view(overlay_view_key: usize) { + let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { + return; + } + + unsafe { + let superview: *mut Object = objc::msg_send![overlay_view, superview]; + if !superview.is_null() { + let _: () = objc::msg_send![overlay_view, removeFromSuperview]; + } + } +} + +#[cfg(target_os = "macos")] +pub(super) fn macos_install_overlay_cursor_rect_support( + window: &Window, +) -> std::result::Result { + let _ = MainThreadMarker::new().ok_or_else(|| { + String::from("Installing macOS overlay cursor rect support requires the main thread.") + })?; + let Some(host_view) = macos_overlay_window_ns_view(window) else { + return Err(String::from("Overlay cursor rect support requires an AppKit window handle.")); + }; + let bounds: NSRect = unsafe { objc::msg_send![host_view, bounds] }; + let overlay_class = macos_overlay_cursor_view_class(); + + let overlay_view: *mut Object = unsafe { + let overlay_view: *mut Object = objc::msg_send![overlay_class, alloc]; + objc::msg_send![overlay_view, initWithFrame: bounds] + }; + if overlay_view.is_null() { + return Err(String::from("Failed to create macOS overlay cursor view.")); + } + + unsafe { + const NS_VIEW_WIDTH_SIZABLE: usize = 2; + const NS_VIEW_HEIGHT_SIZABLE: usize = 16; + let _: () = objc::msg_send![overlay_view, setAutoresizingMask: NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE]; + let _: () = objc::msg_send![host_view, addSubview: overlay_view]; + } + + Ok(MacOSOverlayCursorRectSupport::new(overlay_view as usize)) +} + #[cfg(target_os = "macos")] fn macos_activate_app() { unsafe { diff --git a/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs index 3926deb3..3570696c 100644 --- a/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/cursor_context_runtime.rs @@ -8,6 +8,17 @@ use crate::overlay::{ }; impl OverlaySession { + fn preferred_device_cursor_point(&self) -> Option { + if let (Some(event_cursor_at), Some((_event_monitor, event_global))) = + (self.last_event_cursor_at, self.last_event_cursor) + && event_cursor_at.elapsed() <= LIVE_EVENT_CURSOR_CACHE_TTL + { + return Some(event_global); + } + + self.state.cursor + } + pub(super) fn initialize_cursor_state_for_cursor( &mut self, cursor: GlobalPoint, @@ -173,11 +184,32 @@ impl OverlaySession { &self, raw: GlobalPoint, ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { - if let Some(monitor) = self.monitor_at(raw) { - return Some((monitor, raw, DeviceCursorPointSource::DevicePoints)); - } + let monitors: Vec<_> = self.windows.values().map(|window| window.monitor).collect(); + + Self::resolve_device_cursor_point_for_monitors( + &monitors, + raw, + self.preferred_device_cursor_point(), + ) + } + + pub(super) fn resolve_device_cursor_point_for_monitors( + monitors: &[MonitorRect], + raw: GlobalPoint, + preferred: Option, + ) -> Option<(MonitorRect, GlobalPoint, DeviceCursorPointSource)> { + let mut candidates = Vec::with_capacity(monitors.len().saturating_mul(2)); + + for &monitor in monitors { + if monitor.contains(raw) { + Self::push_device_cursor_candidate( + &mut candidates, + monitor, + raw, + DeviceCursorPointSource::DevicePoints, + ); + } - for monitor in self.windows.values().map(|window| window.monitor) { let sf = f64::from(monitor.scale_factor()).max(1.0); let origin_px_x = (monitor.origin.x as f64 * sf).round() as i64; let origin_px_y = (monitor.origin.y as f64 * sf).round() as i64; @@ -210,11 +242,53 @@ impl OverlaySession { ); if monitor.contains(candidate) { - return Some((monitor, candidate, DeviceCursorPointSource::DevicePixelsFallback)); + Self::push_device_cursor_candidate( + &mut candidates, + monitor, + candidate, + DeviceCursorPointSource::DevicePixelsFallback, + ); } } - None + let preferred = preferred.unwrap_or(raw); + + candidates.into_iter().min_by_key(|(_, global, source)| { + ( + Self::device_cursor_distance_sq(*global, preferred), + Self::device_cursor_source_rank(*source), + ) + }) + } + + fn push_device_cursor_candidate( + candidates: &mut Vec<(MonitorRect, GlobalPoint, DeviceCursorPointSource)>, + monitor: MonitorRect, + global: GlobalPoint, + source: DeviceCursorPointSource, + ) { + if candidates.iter().any(|(existing_monitor, existing_global, _)| { + *existing_monitor == monitor && *existing_global == global + }) { + return; + } + + candidates.push((monitor, global, source)); + } + + fn device_cursor_distance_sq(a: GlobalPoint, b: GlobalPoint) -> i64 { + let dx = i64::from(a.x) - i64::from(b.x); + let dy = i64::from(a.y) - i64::from(b.y); + + dx.saturating_mul(dx).saturating_add(dy.saturating_mul(dy)) + } + + fn device_cursor_source_rank(source: DeviceCursorPointSource) -> u8 { + match source { + DeviceCursorPointSource::DevicePoints => 0, + DeviceCursorPointSource::DevicePixelsFallback => 1, + DeviceCursorPointSource::EventRecentFallback => 2, + } } pub(super) fn resolve_live_cursor_point( diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index fe97c1c7..bd30f758 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -10,6 +10,8 @@ use winit::window::Window; use self::hud_rendering::LiveLoupeTexture; use self::hud_surface::{HudBg, HudBlurUniformRaw}; +#[cfg(target_os = "macos")] +use crate::overlay::MacOSOverlayCursorRectSupport; #[allow(unused_imports)] use crate::overlay::{ self, AcquiredSurfaceFrame, Adapter, AddressMode, Arc, BindGroupLayout, BindingResource, @@ -138,6 +140,8 @@ pub(super) struct OverlayWindow { pub(super) window: Arc, pub(super) renderer: WindowRenderer, pub(super) refresh_rate_millihertz: Option, + #[cfg(target_os = "macos")] + pub(super) cursor_rects: MacOSOverlayCursorRectSupport, } pub(super) struct GpuContext { diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 55e50493..2fd5eea6 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -9,12 +9,13 @@ use crate::overlay::rendering::{ }; #[allow(unused_imports)] use crate::overlay::{ - self, Align, Align2, Area, Color32, CornerRadius, - FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS, - FROZEN_SELECTION_RESIZE_HANDLE_BORDER_GAP_POINTS, + self, Align, Align2, Area, Color32, CornerRadius, FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX, + FROZEN_SELECTION_RESIZE_HANDLE_CENTER_DOT_RADIUS_POINTS, + FROZEN_SELECTION_RESIZE_HANDLE_CORNER_KEEPOUT_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_OUTER_RADIUS_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, @@ -797,7 +798,7 @@ impl WindowRenderer { theme, SelectionScrimStyle { scrim_fill: Self::frozen_selection_scrim_color(theme), - stroke_width_override: Some(FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS), + stroke_width_override: Some(FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX), exclude_resize_handle_corners, }, selection_dashed_border_cache, @@ -907,7 +908,7 @@ impl WindowRenderer { return false; }; let corner_keepout = exclude_resize_handle_corners - .then_some(FROZEN_SELECTION_RESIZE_HANDLE_ARM_LENGTH_POINTS + metrics.gap_length); + .then_some(FROZEN_SELECTION_RESIZE_HANDLE_CORNER_KEEPOUT_POINTS); let segments = Self::selection_dashed_border_cached_segments( selection_dashed_border_cache, border_rect, @@ -936,7 +937,7 @@ impl WindowRenderer { ) -> (Stroke, Stroke) { let _ = theme; let outline = Stroke::new( - metrics.stroke_width + 1.15, + metrics.stroke_width + 0.75, Color32::from_rgba_unmultiplied(229, 247, 255, 116), ); let stroke = Stroke::new( @@ -963,7 +964,7 @@ impl WindowRenderer { let _ = theme; let color = Color32::from_rgba_unmultiplied(229, 247, 255, 124); - Stroke::new(FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS + 0.95, color) + Stroke::new(FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS + 0.6, color) } fn frozen_selection_resize_handle_stroke(theme: HudTheme) -> Stroke { @@ -975,39 +976,14 @@ impl WindowRenderer { ) } - 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); + fn frozen_selection_resize_handle_center(handle: FrozenSelectionResizeHandleGeometry) -> Pos2 { + handle.hit_rect.center() + } - [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); + fn frozen_selection_resize_handle_center_dot_color(theme: HudTheme) -> Color32 { + let _ = theme; - [Pos2::new(elbow.x - arm, elbow.y), elbow, Pos2::new(elbow.x, elbow.y - arm)] - }, - } + Color32::from_rgba_unmultiplied(167, 223, 255, 252) } pub(in crate::overlay) fn render_frozen_selection_resize_handles( @@ -1015,18 +991,28 @@ impl WindowRenderer { 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); + let center_dot_color = Self::frozen_selection_resize_handle_center_dot_color(theme); for handle in Self::frozen_selection_resize_handles(capture_rect) { - let points = Self::frozen_selection_resize_handle_points(handle, border_outset); + let center = Self::frozen_selection_resize_handle_center(handle); - painter.add(Shape::line(points.to_vec(), outline_stroke)); - painter.add(Shape::line(points.to_vec(), stroke)); + painter.circle_stroke( + center, + FROZEN_SELECTION_RESIZE_HANDLE_OUTER_RADIUS_POINTS, + outline_stroke, + ); + painter.circle_stroke( + center, + FROZEN_SELECTION_RESIZE_HANDLE_OUTER_RADIUS_POINTS, + stroke, + ); + painter.circle_filled( + center, + FROZEN_SELECTION_RESIZE_HANDLE_CENTER_DOT_RADIUS_POINTS, + center_dot_color, + ); } true diff --git a/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs index b6e03915..5912f048 100644 --- a/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/live_runtime.rs @@ -2,6 +2,8 @@ use image::RgbaImage; #[cfg(target_os = "macos")] use crate::live_frame_stream_macos::MacLiveFrameStream; +#[cfg(target_os = "macos")] +use crate::overlay::DeviceCursorPointSource; #[allow(unused_imports)] use crate::overlay::tests::{ self, Duration, GlobalPoint, HudRedrawSummary, LoupeSample, MonitorRect, MonitorRectPoints, @@ -506,6 +508,38 @@ fn monitor_for_cursor_in_rects_finds_matching_monitor_without_windows() { ); } +#[cfg(target_os = "macos")] +#[test] +fn resolve_device_cursor_point_prefers_recent_cursor_when_scaled_coordinates_are_ambiguous() { + let monitor = tests::test_monitor_with_scale(1_000, 800, 2_000); + let raw = GlobalPoint::new(190, 230); + + assert_eq!( + OverlaySession::resolve_device_cursor_point_for_monitors( + &[monitor], + raw, + Some(GlobalPoint::new(95, 115)), + ), + Some((monitor, GlobalPoint::new(95, 115), DeviceCursorPointSource::DevicePixelsFallback)) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn resolve_device_cursor_point_keeps_direct_points_when_they_match_recent_cursor() { + let monitor = tests::test_monitor_with_scale(1_000, 800, 2_000); + let raw = GlobalPoint::new(190, 230); + + assert_eq!( + OverlaySession::resolve_device_cursor_point_for_monitors( + &[monitor], + raw, + Some(GlobalPoint::new(190, 230)), + ), + Some((monitor, GlobalPoint::new(190, 230), DeviceCursorPointSource::DevicePoints)) + ); +} + #[cfg(target_os = "macos")] #[test] fn startup_live_rgb_plan_keeps_focus_independent_from_seed_monitor() { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 7b800d52..d59985ec 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -434,7 +434,7 @@ fn frozen_selection_cursor_icon_uses_corner_resize_hover() { 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); + assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::NwseResize); session.state.cursor = Some(GlobalPoint::new(150, 180)); @@ -462,7 +462,76 @@ fn frozen_selection_cursor_icon_tracks_active_resize_drag() { press_cursor_y: 360, }; - assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::SeResize); + assert_eq!(session.frozen_selection_cursor_icon_for_monitor(monitor), CursorIcon::NwseResize); +} + +#[cfg(target_os = "macos")] +#[test] +fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_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; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(95.0, 115.0)), + Some(CursorIcon::NwseResize) + ); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(305.0, 115.0)), + Some(CursorIcon::NeswResize) + ); + + 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: 100, + press_cursor_y: 120, + }; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].icon, CursorIcon::NwseResize); + assert_eq!(rects[0].rect.min, Pos2::ZERO); + assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); +} + +#[cfg(target_os = "macos")] +#[test] +fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_handles() { + let monitor = tests::test_monitor(); + let capture_rect = RectPoints::new(100, 120, 8, 8); + 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; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + let top_overlap = Pos2::new(107.0, 117.0); + let center_inside = Pos2::new(104.0, 124.0); + + assert_eq!( + WindowRenderer::frozen_selection_resize_hit_test(capture_rect, top_overlap), + Some(FrozenSelectionCorner::TopRight) + ); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, top_overlap), + Some(CursorIcon::NeswResize) + ); + assert_eq!(WindowRenderer::frozen_selection_resize_hit_test(capture_rect, center_inside), None); + assert_eq!(overlay::overlay_cursor_rect_icon_at_point(&rects, center_inside), None); } #[test] diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 3da37d76..244010d2 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -597,6 +597,8 @@ impl OverlaySession { #[cfg(target_os = "macos")] overlay::macos_configure_overlay_window_mouse_moved_events(window.as_ref()); + #[cfg(target_os = "macos")] + let cursor_rects = overlay::macos_install_overlay_cursor_rect_support(window.as_ref())?; let refresh_rate_millihertz = window.current_monitor().and_then(|monitor| monitor.refresh_rate_millihertz()); @@ -616,7 +618,14 @@ impl OverlaySession { self.windows.insert( window.id(), - OverlayWindow { monitor: monitor_rect, window, renderer, refresh_rate_millihertz }, + OverlayWindow { + monitor: monitor_rect, + window, + renderer, + refresh_rate_millihertz, + #[cfg(target_os = "macos")] + cursor_rects, + }, ); } From 7c2bb7875b72087a8b56e897c41706a14abb32bd Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 01:47:36 +0800 Subject: [PATCH 2/8] {"schema":"maestro/commit/1","summary":"fix ci lint for frozen handle cursor changes","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 203 ++++++++++-------- .../src/overlay/tests/rendering_behaviors.rs | 2 + .../src/overlay/window_runtime.rs | 2 +- 3 files changed, 114 insertions(+), 93 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 99a6140d..06d92f57 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -29,16 +29,18 @@ use std::mem; use std::panic; use std::ptr; use std::slice; +#[cfg(target_os = "macos")] +use std::sync::OnceLock; use std::{ borrow::Cow, cmp::Ordering, collections::{HashMap, HashSet}, path::PathBuf, - sync::{Arc, Mutex, OnceLock}, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use color_eyre::eyre::{self, Result, WrapErr}; +use color_eyre::eyre::{self, Report, WrapErr}; #[cfg(not(target_os = "macos"))] use device_query::DeviceQuery; use egui::FullOutput; @@ -65,6 +67,7 @@ use image::{ }; #[cfg(target_os = "macos")] use objc::declare::ClassDecl; +use objc::runtime::Sel; #[cfg(target_os = "macos")] use objc::runtime::{BOOL, Class, Object, YES}; #[cfg(target_os = "macos")] @@ -215,14 +218,16 @@ type ExternalScrollInputDrainReader = Arc Vec + Send + Sync>; #[cfg(target_os = "macos")] -type ScrollCaptureStartGuard = Arc Result + Send + Sync>; +type ScrollCaptureStartGuard = Arc color_eyre::eyre::Result + Send + Sync>; #[cfg(target_os = "macos")] -type ScrollCaptureStartingHook = Arc Result<()> + Send + Sync>; +type ScrollCaptureStartingHook = Arc color_eyre::eyre::Result<()> + Send + Sync>; #[cfg(target_os = "macos")] type ScrollCaptureStartedHook = Arc; +type Result = std::result::Result; + #[cfg(target_os = "macos")] const KCG_HID_EVENT_TAP: u32 = 0; #[cfg(target_os = "macos")] @@ -664,29 +669,6 @@ enum AcquiredSurfaceFrame { Skipped(SurfaceFrameSkipReason), } -#[derive(Clone, Copy, Debug, PartialEq)] -struct OverlayCursorRect { - rect: Rect, - icon: CursorIcon, -} -impl OverlayCursorRect { - const fn new(rect: Rect, icon: CursorIcon) -> Self { - Self { rect, icon } - } -} - -fn overlay_cursor_rect_icon_at_point( - rects: &[OverlayCursorRect], - point: Pos2, -) -> Option { - rects.iter().find(|entry| entry.rect.contains(point)).map(|entry| entry.icon) -} - -fn sort_unique_axis_values(values: &mut Vec) { - values.sort_by(f32::total_cmp); - values.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON); -} - #[cfg(target_os = "macos")] pub(super) struct MacOSOverlayCursorRectSupport { view_key: usize, @@ -699,9 +681,11 @@ impl MacOSOverlayCursorRectSupport { fn sync_cursor_rects(&self, window: &Window, rects: &[OverlayCursorRect]) { macos_resize_overlay_cursor_view(window, self.view_key); + if macos_set_overlay_view_cursor_rects(self.view_key, rects) { macos_invalidate_overlay_cursor_rects(self.view_key); } + macos_apply_overlay_cursor_for_current_pointer(self.view_key); } } @@ -1814,6 +1798,7 @@ impl OverlaySession { } } + #[cfg(target_os = "macos")] fn frozen_selection_cursor_rects_for_monitor( &self, monitor: MonitorRect, @@ -1854,6 +1839,7 @@ impl OverlaySession { .collect() } + #[cfg(target_os = "macos")] fn frozen_selection_hover_cursor_rects(capture_rect: RectPoints) -> Vec { let selection_rect = Rect::from_min_size( Pos2::new(capture_rect.x as f32, capture_rect.y as f32), @@ -1892,12 +1878,14 @@ impl OverlaySession { for x_pair in x_edges.windows(2) { let [min_x, max_x] = [x_pair[0], x_pair[1]]; + if max_x <= min_x { continue; } for y_pair in y_edges.windows(2) { let [min_y, max_y] = [y_pair[0], y_pair[1]]; + if max_y <= min_y { continue; } @@ -1927,7 +1915,6 @@ impl OverlaySession { #[cfg(not(target_os = "macos"))] overlay_window.window.set_cursor(icon); - #[cfg(target_os = "macos")] overlay_window.cursor_rects.sync_cursor_rects( overlay_window.window.as_ref(), @@ -5605,6 +5592,19 @@ impl Default for OverlaySession { } } +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, PartialEq)] +struct OverlayCursorRect { + rect: Rect, + icon: CursorIcon, +} +#[cfg(target_os = "macos")] +impl OverlayCursorRect { + const fn new(rect: Rect, icon: CursorIcon) -> Self { + Self { rect, icon } + } +} + #[derive(Debug)] struct OverlayExitMetadata<'a> { exit_kind: &'static str, @@ -5665,6 +5665,34 @@ struct MacOSCGPoint { y: f64, } +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Clone, Copy)] +struct MacOSOverlayPoint { + x: f64, + y: f64, +} +#[cfg(target_os = "macos")] +unsafe impl Encode for MacOSOverlayPoint { + fn encode() -> Encoding { + unsafe { Encoding::from_str("{CGPoint=dd}") } + } +} + +#[cfg(target_os = "macos")] +fn overlay_cursor_rect_icon_at_point( + rects: &[OverlayCursorRect], + point: Pos2, +) -> Option { + rects.iter().find(|entry| entry.rect.contains(point)).map(|entry| entry.icon) +} + +#[cfg(target_os = "macos")] +fn sort_unique_axis_values(values: &mut Vec) { + values.sort_by(f32::total_cmp); + values.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON); +} + fn should_request_overlay_redraw_after_surface_skip( reason: SurfaceFrameSkipReason, now: Instant, @@ -5770,6 +5798,39 @@ unsafe extern "C" { fn CFRelease(obj: CFTypeRef); } +#[cfg(target_os = "macos")] +fn macos_install_overlay_cursor_rect_support( + window: &Window, +) -> std::result::Result { + let _ = MainThreadMarker::new().ok_or_else(|| { + String::from("Installing macOS overlay cursor rect support requires the main thread.") + })?; + let Some(host_view) = macos_overlay_window_ns_view(window) else { + return Err(String::from("Overlay cursor rect support requires an AppKit window handle.")); + }; + let bounds: NSRect = unsafe { objc::msg_send![host_view, bounds] }; + let overlay_class = macos_overlay_cursor_view_class(); + let overlay_view: *mut Object = unsafe { + let overlay_view: *mut Object = objc::msg_send![overlay_class, alloc]; + + objc::msg_send![overlay_view, initWithFrame: bounds] + }; + + if overlay_view.is_null() { + return Err(String::from("Failed to create macOS overlay cursor view.")); + } + + unsafe { + const NS_VIEW_WIDTH_SIZABLE: usize = 2; + const NS_VIEW_HEIGHT_SIZABLE: usize = 16; + + let _: () = objc::msg_send![overlay_view, setAutoresizingMask: NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE]; + let _: () = objc::msg_send![host_view, addSubview: overlay_view]; + } + + Ok(MacOSOverlayCursorRectSupport::new(overlay_view as usize)) +} + #[cfg(target_os = "macos")] fn macos_mouse_location() -> Option { let event = unsafe { CGEventCreate(ptr::null()) }; @@ -5800,10 +5861,10 @@ fn macos_set_overlay_view_cursor_rects(view_key: usize, rects: &[OverlayCursorRe Ok(mut guard) => { let unchanged = guard.get(&view_key).is_some_and(|existing| existing.as_slice() == rects); + if unchanged || (rects.is_empty() && !guard.contains_key(&view_key)) { return false; } - if rects.is_empty() { guard.remove(&view_key); } else { @@ -5814,10 +5875,10 @@ fn macos_set_overlay_view_cursor_rects(view_key: usize, rects: &[OverlayCursorRe let mut guard = poisoned.into_inner(); let unchanged = guard.get(&view_key).is_some_and(|existing| existing.as_slice() == rects); + if unchanged || (rects.is_empty() && !guard.contains_key(&view_key)) { return false; } - if rects.is_empty() { guard.remove(&view_key); } else { @@ -5848,6 +5909,7 @@ fn macos_cursor_object_for_icon(icon: CursorIcon) -> *mut Object { CursorIcon::Grabbing => unsafe { objc::msg_send![cursor_class, closedHandCursor] }, CursorIcon::NeswResize => unsafe { let responds: bool = objc::msg_send![cursor_class, respondsToSelector: objc::sel!(_windowResizeNorthEastSouthWestCursor)]; + if responds { objc::msg_send![cursor_class, performSelector: objc::sel!(_windowResizeNorthEastSouthWestCursor)] } else { @@ -5856,6 +5918,7 @@ fn macos_cursor_object_for_icon(icon: CursorIcon) -> *mut Object { }, CursorIcon::NwseResize => unsafe { let responds: bool = objc::msg_send![cursor_class, respondsToSelector: objc::sel!(_windowResizeNorthWestSouthEastCursor)]; + if responds { objc::msg_send![cursor_class, performSelector: objc::sel!(_windowResizeNorthWestSouthEastCursor)] } else { @@ -5867,44 +5930,25 @@ fn macos_cursor_object_for_icon(icon: CursorIcon) -> *mut Object { } #[cfg(target_os = "macos")] -#[repr(C)] -#[derive(Clone, Copy)] -struct MacOSOverlayPoint { - x: f64, - y: f64, -} - -#[cfg(target_os = "macos")] -unsafe impl Encode for MacOSOverlayPoint { - fn encode() -> Encoding { - unsafe { Encoding::from_str("{CGPoint=dd}") } - } -} - -#[cfg(target_os = "macos")] -extern "C" fn macos_overlay_cursor_view_is_flipped( - _this: &Object, - _cmd: objc::runtime::Sel, -) -> BOOL { +extern "C" fn macos_overlay_cursor_view_is_flipped(_this: &Object, _cmd: Sel) -> BOOL { let _ = _cmd; + YES } #[cfg(target_os = "macos")] extern "C" fn macos_overlay_cursor_view_hit_test( _this: &Object, - _cmd: objc::runtime::Sel, + _cmd: Sel, _point: MacOSOverlayPoint, ) -> *mut Object { let _ = (_cmd, _point); + ptr::null_mut() } #[cfg(target_os = "macos")] -extern "C" fn macos_overlay_cursor_view_reset_cursor_rects( - this: &Object, - _cmd: objc::runtime::Sel, -) { +extern "C" fn macos_overlay_cursor_view_reset_cursor_rects(this: &Object, _cmd: Sel) { let _ = _cmd; let view_key = (this as *const Object) as usize; let Some(entries) = macos_overlay_view_cursor_rect_entries(view_key) else { @@ -5913,6 +5957,7 @@ extern "C" fn macos_overlay_cursor_view_reset_cursor_rects( for entry in entries { let cursor = macos_cursor_object_for_icon(entry.icon); + if cursor.is_null() { continue; } @@ -5944,18 +5989,16 @@ fn macos_overlay_cursor_view_class() -> *const Class { unsafe { decl.add_method( objc::sel!(isFlipped), - macos_overlay_cursor_view_is_flipped - as extern "C" fn(&Object, objc::runtime::Sel) -> BOOL, + macos_overlay_cursor_view_is_flipped as extern "C" fn(&Object, Sel) -> BOOL, ); decl.add_method( objc::sel!(hitTest:), macos_overlay_cursor_view_hit_test - as extern "C" fn(&Object, objc::runtime::Sel, MacOSOverlayPoint) -> *mut Object, + as extern "C" fn(&Object, Sel, MacOSOverlayPoint) -> *mut Object, ); decl.add_method( objc::sel!(resetCursorRects), - macos_overlay_cursor_view_reset_cursor_rects - as extern "C" fn(&Object, objc::runtime::Sel), + macos_overlay_cursor_view_reset_cursor_rects as extern "C" fn(&Object, Sel), ); } @@ -5981,6 +6024,7 @@ fn macos_resize_overlay_cursor_view(window: &Window, overlay_view_key: usize) { return; }; let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { return; } @@ -5995,12 +6039,14 @@ fn macos_resize_overlay_cursor_view(window: &Window, overlay_view_key: usize) { #[cfg(target_os = "macos")] fn macos_invalidate_overlay_cursor_rects(overlay_view_key: usize) { let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { return; } unsafe { let ns_window: *mut Object = objc::msg_send![overlay_view, window]; + if ns_window.is_null() { return; } @@ -6012,12 +6058,14 @@ fn macos_invalidate_overlay_cursor_rects(overlay_view_key: usize) { #[cfg(target_os = "macos")] fn macos_overlay_view_current_local_point(overlay_view_key: usize) -> Option { let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { return None; } unsafe { let ns_window: *mut Object = objc::msg_send![overlay_view, window]; + if ns_window.is_null() { return None; } @@ -6037,10 +6085,10 @@ fn macos_apply_overlay_cursor_for_current_pointer(overlay_view_key: usize) { let Some(local_point) = macos_overlay_view_current_local_point(overlay_view_key) else { return; }; - let icon = overlay_cursor_rect_icon_at_point(&entries, local_point).unwrap_or(CursorIcon::Default); let cursor = macos_cursor_object_for_icon(icon); + if cursor.is_null() { return; } @@ -6053,49 +6101,20 @@ fn macos_apply_overlay_cursor_for_current_pointer(overlay_view_key: usize) { #[cfg(target_os = "macos")] fn macos_remove_overlay_cursor_view(overlay_view_key: usize) { let overlay_view = overlay_view_key as *mut Object; + if overlay_view.is_null() { return; } unsafe { let superview: *mut Object = objc::msg_send![overlay_view, superview]; + if !superview.is_null() { let _: () = objc::msg_send![overlay_view, removeFromSuperview]; } } } -#[cfg(target_os = "macos")] -pub(super) fn macos_install_overlay_cursor_rect_support( - window: &Window, -) -> std::result::Result { - let _ = MainThreadMarker::new().ok_or_else(|| { - String::from("Installing macOS overlay cursor rect support requires the main thread.") - })?; - let Some(host_view) = macos_overlay_window_ns_view(window) else { - return Err(String::from("Overlay cursor rect support requires an AppKit window handle.")); - }; - let bounds: NSRect = unsafe { objc::msg_send![host_view, bounds] }; - let overlay_class = macos_overlay_cursor_view_class(); - - let overlay_view: *mut Object = unsafe { - let overlay_view: *mut Object = objc::msg_send![overlay_class, alloc]; - objc::msg_send![overlay_view, initWithFrame: bounds] - }; - if overlay_view.is_null() { - return Err(String::from("Failed to create macOS overlay cursor view.")); - } - - unsafe { - const NS_VIEW_WIDTH_SIZABLE: usize = 2; - const NS_VIEW_HEIGHT_SIZABLE: usize = 16; - let _: () = objc::msg_send![overlay_view, setAutoresizingMask: NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE]; - let _: () = objc::msg_send![host_view, addSubview: overlay_view]; - } - - Ok(MacOSOverlayCursorRectSupport::new(overlay_view as usize)) -} - #[cfg(target_os = "macos")] fn macos_activate_app() { unsafe { @@ -6137,7 +6156,7 @@ fn macos_make_window_key(window: &Window) { fn macos_post_scroll_wheel_event( delta: MacOSScrollWheelEvent, target_point: GlobalPoint, -) -> Result<()> { +) -> color_eyre::eyre::Result<()> { let units = delta.units; let wheel1 = delta.posted_y; let wheel2 = delta.posted_x; diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index d59985ec..0537c521 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -479,6 +479,7 @@ fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_resize_ session.frozen_capture_source = FrozenCaptureSource::DragRegion; let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + assert_eq!( overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(95.0, 115.0)), Some(CursorIcon::NwseResize) @@ -499,6 +500,7 @@ fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_resize_ }; let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + assert_eq!(rects.len(), 1); assert_eq!(rects[0].icon, CursorIcon::NwseResize); assert_eq!(rects[0].rect.min, Pos2::ZERO); diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 244010d2..e9b31759 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -597,9 +597,9 @@ impl OverlaySession { #[cfg(target_os = "macos")] overlay::macos_configure_overlay_window_mouse_moved_events(window.as_ref()); + #[cfg(target_os = "macos")] let cursor_rects = overlay::macos_install_overlay_cursor_rect_support(window.as_ref())?; - let refresh_rate_millihertz = window.current_monitor().and_then(|monitor| monitor.refresh_rate_millihertz()); From d3d4220f2d39edbb9382cbd9ba323d7ad7c1dc00 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 02:41:31 +0800 Subject: [PATCH 3/8] {"schema":"maestro/commit/1","summary":"repair macos frozen cursor review follow-up and linux ci","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 40 ++++++++++++++----- .../src/overlay/tests/rendering_behaviors.rs | 33 +++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index c3ed1def..2a4e3f50 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -67,6 +67,7 @@ use image::{ }; #[cfg(target_os = "macos")] use objc::declare::ClassDecl; +#[cfg(target_os = "macos")] use objc::runtime::Sel; #[cfg(target_os = "macos")] use objc::runtime::{BOOL, Class, Object, YES}; @@ -1909,6 +1910,28 @@ impl OverlaySession { return Vec::new(); } + let overlay_bounds = + Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); + + if let Some((target_monitor, capture_rect)) = self.frozen_mosaic_drag_target() { + if target_monitor != monitor { + return Vec::new(); + } + if self.frozen_mosaic_drag.active { + return vec![OverlayCursorRect::new(overlay_bounds, CursorIcon::Crosshair)]; + } + + let capture_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), + ) + .intersect(overlay_bounds); + + return (capture_rect.width() > 0.0 && capture_rect.height() > 0.0) + .then_some(vec![OverlayCursorRect::new(capture_rect, CursorIcon::Crosshair)]) + .unwrap_or_default(); + } + let Some((target_monitor, capture_rect)) = self.frozen_selection_drag_target() else { return Vec::new(); }; @@ -1916,10 +1939,6 @@ impl OverlaySession { if target_monitor != monitor { return Vec::new(); } - - let overlay_bounds = - Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); - if self.frozen_selection_drag.active { return match self.frozen_selection_drag.interaction { FrozenSelectionInteractionKind::Resize(corner) => vec![OverlayCursorRect::new( @@ -4218,13 +4237,13 @@ impl OverlaySession { ) -> OverlayControl { self.reset_toolbar_pointer_state(); - match state { - ElementState::Pressed => { - let cursor = self.current_frozen_interaction_cursor(); + match state { + ElementState::Pressed => { + let cursor = self.current_frozen_interaction_cursor(); - if !self.begin_frozen_selection_drag(cursor) { - let _ = self.begin_frozen_mosaic_drag(cursor); - } + if !self.begin_frozen_selection_drag(cursor) { + let _ = self.begin_frozen_mosaic_drag(cursor); + } self.sync_overlay_cursor_icons(); }, @@ -6543,6 +6562,7 @@ fn macos_install_overlay_cursor_rect_support( let _: () = objc::msg_send![overlay_view, setAutoresizingMask: NS_VIEW_WIDTH_SIZABLE | NS_VIEW_HEIGHT_SIZABLE]; let _: () = objc::msg_send![host_view, addSubview: overlay_view]; + let _: () = objc::msg_send![overlay_view, release]; } Ok(MacOSOverlayCursorRectSupport::new(overlay_view as usize)) diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 2a3aa203..171ebe98 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -762,6 +762,39 @@ fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_resize_ assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); } +#[cfg(target_os = "macos")] +#[test] +fn frozen_mosaic_cursor_rects_preserve_crosshair_hover_and_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.authoritative_frozen_capture_ready = true; + session.state.frozen_capture_rect = Some(capture_rect); + session.frozen_capture_source = FrozenCaptureSource::DragRegion; + session.toolbar_state.selected_tool = FrozenToolbarTool::Mosaic; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(150.0, 180.0)), + Some(CursorIcon::Crosshair) + ); + assert_eq!(overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(80.0, 100.0)), None); + + session.frozen_mosaic_drag.active = true; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].icon, CursorIcon::Crosshair); + assert_eq!(rects[0].rect.min, Pos2::ZERO); + assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); +} + #[cfg(target_os = "macos")] #[test] fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_handles() { From 55a8639c092c1620747705f31d51e718fb514fcc Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 02:48:41 +0800 Subject: [PATCH 4/8] {"schema":"maestro/commit/1","summary":"repair macos frozen cursor review follow-up","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 5 ++- .../src/overlay/tests/rendering_behaviors.rs | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 2a4e3f50..8158175a 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -1945,7 +1945,9 @@ impl OverlaySession { overlay_bounds, Self::frozen_selection_resize_cursor_icon(corner), )], - FrozenSelectionInteractionKind::Move => Vec::new(), + FrozenSelectionInteractionKind::Move => { + vec![OverlayCursorRect::new(overlay_bounds, CursorIcon::Grabbing)] + }, }; } @@ -6642,6 +6644,7 @@ fn macos_cursor_object_for_icon(icon: CursorIcon) -> *mut Object { let cursor_class = objc::class!(NSCursor); match icon { + CursorIcon::Crosshair => unsafe { objc::msg_send![cursor_class, crosshairCursor] }, CursorIcon::Grab => unsafe { objc::msg_send![cursor_class, openHandCursor] }, CursorIcon::Grabbing => unsafe { objc::msg_send![cursor_class, closedHandCursor] }, CursorIcon::NeswResize => unsafe { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 171ebe98..f8188728 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -3,6 +3,8 @@ use egui::LayerId; use egui::Order; use egui::Ui; use image::RgbaImage; +#[cfg(target_os = "macos")] +use objc::runtime::Object; use winit::window::CursorIcon; use crate::OverlayControl; @@ -762,6 +764,36 @@ fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_resize_ assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); } +#[cfg(target_os = "macos")] +#[test] +fn frozen_selection_cursor_rects_preserve_grabbing_cursor_during_move_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::Move, + anchor_rect: capture_rect, + pointer_offset_x: 50, + pointer_offset_y: 60, + press_cursor_x: 150, + press_cursor_y: 180, + }; + + let rects = session.frozen_selection_cursor_rects_for_monitor(monitor); + + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].icon, CursorIcon::Grabbing); + assert_eq!(rects[0].rect.min, Pos2::ZERO); + assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); +} + #[cfg(target_os = "macos")] #[test] fn frozen_mosaic_cursor_rects_preserve_crosshair_hover_and_drag() { @@ -795,6 +827,15 @@ fn frozen_mosaic_cursor_rects_preserve_crosshair_hover_and_drag() { assert_eq!(rects[0].rect.max, Pos2::new(monitor.width as f32, monitor.height as f32)); } +#[cfg(target_os = "macos")] +#[test] +fn macos_cursor_object_maps_crosshair_icon() { + let actual = overlay::macos_cursor_object_for_icon(CursorIcon::Crosshair) as usize; + let expected: *mut Object = unsafe { objc::msg_send![objc::class!(NSCursor), crosshairCursor] }; + + assert_eq!(actual, expected as usize); +} + #[cfg(target_os = "macos")] #[test] fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_handles() { From 4e82f27e642c9d7863edf02dd63818a0f6093fa8 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 09:12:22 +0800 Subject: [PATCH 5/8] {"schema":"maestro/commit/1","summary":"restore macos frozen selection move hover cursor","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 13 ++++++++++--- .../src/overlay/tests/rendering_behaviors.rs | 9 ++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 8158175a..dc4b1804 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -2015,9 +2015,16 @@ impl OverlaySession { let rect = Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y)); let point = rect.center(); - let Some(corner) = - WindowRenderer::frozen_selection_resize_hit_test(capture_rect, point) - else { + let Some(interaction) = Self::frozen_selection_interaction_kind( + capture_rect, + point.x as u32, + point.y as u32, + ) else { + continue; + }; + let FrozenSelectionInteractionKind::Resize(corner) = interaction else { + rects.push(OverlayCursorRect::new(rect, CursorIcon::Grab)); + continue; }; let icon = Self::frozen_selection_resize_cursor_icon(corner); diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index f8188728..c62909b1 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -745,6 +745,10 @@ fn frozen_selection_cursor_rects_use_native_handle_hover_and_full_window_resize_ overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(305.0, 115.0)), Some(CursorIcon::NeswResize) ); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, Pos2::new(150.0, 180.0)), + Some(CursorIcon::Grab) + ); session.frozen_selection_drag = FrozenSelectionDragState { active: true, @@ -871,7 +875,10 @@ fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_hand Some(CursorIcon::NwseResize) ); assert_eq!(WindowRenderer::frozen_selection_resize_hit_test(capture_rect, center_inside), None); - assert_eq!(overlay::overlay_cursor_rect_icon_at_point(&rects, center_inside), None); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, center_inside), + Some(CursorIcon::Grab) + ); } #[test] From bdd6b509e1b580913ea75cac01a0755bfc5ea941 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 09:21:15 +0800 Subject: [PATCH 6/8] {"schema":"maestro/commit/1","summary":"reset macos overlay cursor when cursor rects clear","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 24 ++++++++++++++----- .../src/overlay/tests/rendering_behaviors.rs | 9 +++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index dc4b1804..9a16aa56 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -6824,16 +6824,28 @@ fn macos_overlay_view_current_local_point(overlay_view_key: usize) -> Option, + local_point: Option, +) -> Option { + let local_point = local_point?; + + Some(match entries { + Some(entries) => { + overlay_cursor_rect_icon_at_point(entries, local_point).unwrap_or(CursorIcon::Default) + }, + None => CursorIcon::Default, + }) +} + #[cfg(target_os = "macos")] fn macos_apply_overlay_cursor_for_current_pointer(overlay_view_key: usize) { - let Some(entries) = macos_overlay_view_cursor_rect_entries(overlay_view_key) else { - return; - }; - let Some(local_point) = macos_overlay_view_current_local_point(overlay_view_key) else { + let entries = macos_overlay_view_cursor_rect_entries(overlay_view_key); + let local_point = macos_overlay_view_current_local_point(overlay_view_key); + let Some(icon) = macos_cursor_icon_for_current_pointer(entries.as_deref(), local_point) else { return; }; - let icon = - overlay_cursor_rect_icon_at_point(&entries, local_point).unwrap_or(CursorIcon::Default); let cursor = macos_cursor_object_for_icon(icon); if cursor.is_null() { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index c62909b1..89570263 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -840,6 +840,15 @@ fn macos_cursor_object_maps_crosshair_icon() { assert_eq!(actual, expected as usize); } +#[cfg(target_os = "macos")] +#[test] +fn macos_cursor_icon_defaults_without_active_rect_entries() { + assert_eq!( + overlay::macos_cursor_icon_for_current_pointer(None, Some(Pos2::new(150.0, 180.0))), + Some(CursorIcon::Default) + ); +} + #[cfg(target_os = "macos")] #[test] fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_handles() { From 1bd621d02f1553f597e82caf61dcbc29572d68f3 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 09:35:17 +0800 Subject: [PATCH 7/8] {"schema":"maestro/commit/1","summary":"scope macos overlay cursor updates to active window","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay.rs | 29 ++++++++++++++++++- .../src/overlay/tests/rendering_behaviors.rs | 19 +++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 9a16aa56..688b5590 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -6824,12 +6824,36 @@ fn macos_overlay_view_current_local_point(overlay_view_key: usize) -> Option Option { + let overlay_view = overlay_view_key as *mut Object; + + if overlay_view.is_null() { + return None; + } + + unsafe { + let bounds: NSRect = objc::msg_send![overlay_view, bounds]; + + Some(Rect::from_min_size( + Pos2::new(bounds.origin.x as f32, bounds.origin.y as f32), + Vec2::new(bounds.size.width as f32, bounds.size.height as f32), + )) + } +} + #[cfg(target_os = "macos")] fn macos_cursor_icon_for_current_pointer( entries: Option<&[OverlayCursorRect]>, local_point: Option, + overlay_bounds: Option, ) -> Option { let local_point = local_point?; + let overlay_bounds = overlay_bounds?; + + if !overlay_bounds.contains(local_point) { + return None; + } Some(match entries { Some(entries) => { @@ -6843,7 +6867,10 @@ fn macos_cursor_icon_for_current_pointer( fn macos_apply_overlay_cursor_for_current_pointer(overlay_view_key: usize) { let entries = macos_overlay_view_cursor_rect_entries(overlay_view_key); let local_point = macos_overlay_view_current_local_point(overlay_view_key); - let Some(icon) = macos_cursor_icon_for_current_pointer(entries.as_deref(), local_point) else { + let overlay_bounds = macos_overlay_view_bounds(overlay_view_key); + let Some(icon) = + macos_cursor_icon_for_current_pointer(entries.as_deref(), local_point, overlay_bounds) + else { return; }; let cursor = macos_cursor_object_for_icon(icon); diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs index 89570263..d3c14d4b 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors.rs @@ -844,11 +844,28 @@ fn macos_cursor_object_maps_crosshair_icon() { #[test] fn macos_cursor_icon_defaults_without_active_rect_entries() { assert_eq!( - overlay::macos_cursor_icon_for_current_pointer(None, Some(Pos2::new(150.0, 180.0))), + overlay::macos_cursor_icon_for_current_pointer( + None, + Some(Pos2::new(150.0, 180.0)), + Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 300.0))), + ), Some(CursorIcon::Default) ); } +#[cfg(target_os = "macos")] +#[test] +fn macos_cursor_icon_skips_windows_outside_pointer_bounds() { + assert_eq!( + overlay::macos_cursor_icon_for_current_pointer( + None, + Some(Pos2::new(450.0, 180.0)), + Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 300.0))), + ), + None + ); +} + #[cfg(target_os = "macos")] #[test] fn frozen_selection_cursor_rects_match_resize_hit_test_for_tiny_overlapping_handles() { From 8cd77cddeb79c1cf9696fff10399ce9b0bdba42d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 7 Apr 2026 09:44:06 +0800 Subject: [PATCH 8/8] {"schema":"maestro/commit/1","summary":"drop macos cursor rect support before overlay window","authority":"manual","breaking":false} --- packages/rsnap-overlay/src/overlay/rendering.rs | 5 +++-- packages/rsnap-overlay/src/overlay/window_runtime.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index bd30f758..a4419473 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -137,11 +137,12 @@ pub(super) struct StartupLiveRgbPlan { pub(super) struct OverlayWindow { pub(super) monitor: MonitorRect, + #[cfg(target_os = "macos")] + // Drop cursor rect support before releasing the backing window. + pub(super) cursor_rects: MacOSOverlayCursorRectSupport, pub(super) window: Arc, pub(super) renderer: WindowRenderer, pub(super) refresh_rate_millihertz: Option, - #[cfg(target_os = "macos")] - pub(super) cursor_rects: MacOSOverlayCursorRectSupport, } pub(super) struct GpuContext { diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 7ab71ded..3cbc0c53 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -620,11 +620,11 @@ impl OverlaySession { window.id(), OverlayWindow { monitor: monitor_rect, + #[cfg(target_os = "macos")] + cursor_rects, window, renderer, refresh_rate_millihertz, - #[cfg(target_os = "macos")] - cursor_rects, }, ); }