diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 337f859b..688b5590 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -29,6 +29,8 @@ 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, @@ -38,7 +40,7 @@ use std::{ 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; @@ -64,12 +66,20 @@ 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::Sel; +#[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; @@ -207,14 +217,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")] @@ -323,6 +335,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; @@ -330,9 +343,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 FROZEN_MOSAIC_BLOCK_SIZE_PX: u32 = 12; const FROZEN_EDIT_HISTORY_LIMIT: usize = 24; const WINDOW_CAPTURE_MATTE_LIGHT_RGBA: image::Rgba = image::Rgba([246, 246, 246, 255]); @@ -671,6 +685,45 @@ enum AcquiredSurfaceFrame { Skipped(SurfaceFrameSkipReason), } +#[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 { @@ -1775,13 +1828,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 { if let Some((target_monitor, capture_rect)) = self.frozen_mosaic_drag_target() { if target_monitor != monitor { @@ -1837,6 +1893,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), @@ -1844,11 +1901,190 @@ impl OverlaySession { } } + #[cfg(target_os = "macos")] + fn frozen_selection_cursor_rects_for_monitor( + &self, + monitor: MonitorRect, + ) -> Vec { + if !matches!(self.state.mode, OverlayMode::Frozen) { + 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(); + }; + + if target_monitor != monitor { + return Vec::new(); + } + 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![OverlayCursorRect::new(overlay_bounds, CursorIcon::Grabbing)] + }, + }; + } + + 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() + } + + #[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), + 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(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); + let mut adjusted_min = rect.min; + let mut adjusted_max = rect.max; + + if WindowRenderer::frozen_selection_resize_hit_test( + capture_rect, + Pos2::new(rect.min.x, point.y), + ) != Some(corner) + { + adjusted_min.x = trim_rect_min_edge(adjusted_min.x, adjusted_max.x); + } + if WindowRenderer::frozen_selection_resize_hit_test( + capture_rect, + Pos2::new(rect.max.x, point.y), + ) != Some(corner) + { + adjusted_max.x = trim_rect_max_edge(adjusted_max.x, adjusted_min.x); + } + if WindowRenderer::frozen_selection_resize_hit_test( + capture_rect, + Pos2::new(point.x, rect.min.y), + ) != Some(corner) + { + adjusted_min.y = trim_rect_min_edge(adjusted_min.y, adjusted_max.y); + } + if WindowRenderer::frozen_selection_resize_hit_test( + capture_rect, + Pos2::new(point.x, rect.max.y), + ) != Some(corner) + { + adjusted_max.y = trim_rect_max_edge(adjusted_max.y, adjusted_min.y); + } + if adjusted_max.x <= adjusted_min.x || adjusted_max.y <= adjusted_min.y { + continue; + } + + rects.push(OverlayCursorRect::new( + Rect::from_min_max(adjusted_min, adjusted_max), + icon, + )); + } + } + + 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), + ); } } @@ -3755,6 +3991,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; @@ -3999,7 +4248,7 @@ impl OverlaySession { match state { ElementState::Pressed => { - let cursor = self.current_device_cursor(); + let cursor = self.current_frozen_interaction_cursor(); if !self.begin_frozen_selection_drag(cursor) { let _ = self.begin_frozen_mosaic_drag(cursor); @@ -6061,6 +6310,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(Clone, Debug)] struct FrozenImagePatch { rect: RectPoints, @@ -6134,6 +6396,48 @@ 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); +} + +#[cfg(target_os = "macos")] +fn trim_rect_min_edge(min: f32, max: f32) -> f32 { + let trimmed = min.next_up(); + + if trimmed < max { trimmed } else { max } +} + +#[cfg(target_os = "macos")] +fn trim_rect_max_edge(max: f32, min: f32) -> f32 { + let trimmed = max.next_down(); + + if trimmed > min { trimmed } else { min } +} + fn should_request_overlay_redraw_after_surface_skip( reason: SurfaceFrameSkipReason, now: Instant, @@ -6239,6 +6543,40 @@ 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]; + let _: () = objc::msg_send![overlay_view, release]; + } + + Ok(MacOSOverlayCursorRectSupport::new(overlay_view as usize)) +} + #[cfg(target_os = "macos")] fn macos_mouse_location() -> Option { let event = unsafe { CGEventCreate(ptr::null()) }; @@ -6254,6 +6592,315 @@ 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::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 { + 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")] +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: 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: 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, Sel) -> BOOL, + ); + decl.add_method( + objc::sel!(hitTest:), + macos_overlay_cursor_view_hit_test + 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, 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_overlay_view_bounds(overlay_view_key: usize) -> 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) => { + 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 entries = macos_overlay_view_cursor_rect_entries(overlay_view_key); + let local_point = macos_overlay_view_current_local_point(overlay_view_key); + 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); + + 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")] fn macos_activate_app() { unsafe { @@ -6295,7 +6942,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/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..a4419473 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, @@ -135,6 +137,9 @@ 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, diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index ac366ffd..4f9b4e2c 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, @@ -817,7 +818,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, @@ -927,7 +928,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, @@ -956,7 +957,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( @@ -983,7 +984,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 { @@ -995,39 +996,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( @@ -1035,18 +1011,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 c0a6ab8d..d3c14d4b 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; @@ -689,7 +691,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)); @@ -717,7 +719,192 @@ 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) + ); + 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, + 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_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() { + 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 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 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(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() { + 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 top_overlap_midline_tie = Pos2::new(104.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, top_overlap_midline_tie), + Some(FrozenSelectionCorner::TopLeft) + ); + assert_eq!( + overlay::overlay_cursor_rect_icon_at_point(&rects, top_overlap_midline_tie), + 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), + Some(CursorIcon::Grab) + ); } #[test] diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 46d24b50..3cbc0c53 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -598,6 +598,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, + #[cfg(target_os = "macos")] + cursor_rects, + window, + renderer, + refresh_rate_millihertz, + }, ); }