diff --git a/crates/hearthspace-compositor/src/compositor/cursor.rs b/crates/hearthspace-compositor/src/compositor/cursor.rs index babd65d..5316f46 100644 --- a/crates/hearthspace-compositor/src/compositor/cursor.rs +++ b/crates/hearthspace-compositor/src/compositor/cursor.rs @@ -1,5 +1,6 @@ use smithay::{ backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer}, + input::pointer::CursorIcon as SmithayCursorIcon, utils::Transform, }; use xcursor::parser::parse_xcursor; @@ -40,6 +41,17 @@ const NWSE_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/nws const NESW_RESIZE_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/nesw-resize"); const DESIRED_CURSOR_SIZE: u32 = 24; +pub(super) fn cursor_icon_from_smithay(icon: SmithayCursorIcon) -> CursorIcon { + match icon { + SmithayCursorIcon::Default => CursorIcon::Default, + SmithayCursorIcon::NsResize => CursorIcon::NsResize, + SmithayCursorIcon::EwResize => CursorIcon::EwResize, + SmithayCursorIcon::NwseResize => CursorIcon::NwseResize, + SmithayCursorIcon::NeswResize => CursorIcon::NeswResize, + _ => CursorIcon::Default, + } +} + pub(super) fn standard_software_cursor() -> SoftwareCursor { let image = standard_cursor_image(); let width = i32::try_from(image.width).expect("cursor width must fit in i32"); diff --git a/crates/hearthspace-compositor/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs index 5522414..3309065 100644 --- a/crates/hearthspace-compositor/src/compositor/handlers.rs +++ b/crates/hearthspace-compositor/src/compositor/handlers.rs @@ -457,5 +457,17 @@ impl SeatHandler for App { fn focus_changed(&mut self, _seat: &Seat, _focused: Option<&WlSurface>) {} - fn cursor_image(&mut self, _seat: &Seat, _image: CursorImageStatus) {} + fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { + match image { + CursorImageStatus::Hidden => self.cursor_visible = false, + CursorImageStatus::Named(icon) => { + self.cursor_visible = true; + self.cursor_icon = crate::compositor::cursor::cursor_icon_from_smithay(icon); + } + CursorImageStatus::Surface(_) => { + self.cursor_visible = true; + self.cursor_icon = crate::compositor::cursor::CursorIcon::Default; + } + } + } } diff --git a/crates/hearthspace-compositor/src/compositor/input/pointer.rs b/crates/hearthspace-compositor/src/compositor/input/pointer.rs index 749d8c8..35407b4 100644 --- a/crates/hearthspace-compositor/src/compositor/input/pointer.rs +++ b/crates/hearthspace-compositor/src/compositor/input/pointer.rs @@ -212,8 +212,10 @@ impl App { pub(super) fn apply_pointer_motion(&mut self, location: Point, time: u32) { let previous_location = self.pointer_location; - self.raw_pointer_location = location; - self.pointer_location = clamp_point_to_outputs(location, &self.output_logical_rects()); + let constrained = + constrain_pointer_to_outputs(previous_location, location, &self.output_logical_rects()); + self.pointer_location = constrained.visible; + self.raw_pointer_location = constrained.raw; if self.software_cursor_visible && self.pointer_location != previous_location { self.request_redraw(); } @@ -380,19 +382,112 @@ fn clamp_point_to_outputs( .unwrap_or(location) } +struct PointerConstraint { + visible: Point, + raw: Point, +} + +fn constrain_pointer_to_outputs( + previous: Point, + location: Point, + output_rects: &[Rectangle], +) -> PointerConstraint { + let previous_rect = rect_containing_point(previous, output_rects) + .or_else(|| nearest_rect_to_point(previous, output_rects)); + + if let Some(target_rect) = rect_containing_point(location, output_rects) { + if previous_rect + .map(|previous_rect| rects_are_connected(previous_rect, target_rect)) + .unwrap_or(true) + { + return PointerConstraint { + visible: location, + raw: location, + }; + } + + let visible = previous_rect + .map(|rect| clamp_point_to_rect(location, rect)) + .unwrap_or(location); + return PointerConstraint { + visible, + raw: visible, + }; + } + + let visible = previous_rect + .map(|rect| clamp_point_to_rect(location, rect)) + .unwrap_or_else(|| clamp_point_to_outputs(location, output_rects)); + PointerConstraint { + visible, + raw: visible, + } +} + +fn rect_containing_point( + point: Point, + rects: &[Rectangle], +) -> Option> { + rects + .iter() + .copied() + .find(|rect| point_in_rect(point, *rect)) +} + +fn nearest_rect_to_point( + point: Point, + rects: &[Rectangle], +) -> Option> { + rects.iter().copied().min_by(|a, b| { + squared_distance(point, clamp_point_to_rect(point, *a)) + .partial_cmp(&squared_distance(point, clamp_point_to_rect(point, *b))) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + +fn rects_are_connected(a: Rectangle, b: Rectangle) -> bool { + rectangles_overlap(a, b) + || horizontal_edges_touch_with_overlap(a, b) + || horizontal_edges_touch_with_overlap(b, a) + || vertical_edges_touch_with_overlap(a, b) + || vertical_edges_touch_with_overlap(b, a) +} + +fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { + a.loc.x < b.loc.x + b.size.w.max(1) + && a.loc.x + a.size.w.max(1) > b.loc.x + && a.loc.y < b.loc.y + b.size.h.max(1) + && a.loc.y + a.size.h.max(1) > b.loc.y +} + +fn horizontal_edges_touch_with_overlap( + a: Rectangle, + b: Rectangle, +) -> bool { + a.loc.x + a.size.w.max(1) == b.loc.x && ranges_overlap(a.loc.y, a.size.h, b.loc.y, b.size.h) +} + +fn vertical_edges_touch_with_overlap( + a: Rectangle, + b: Rectangle, +) -> bool { + a.loc.y + a.size.h.max(1) == b.loc.y && ranges_overlap(a.loc.x, a.size.w, b.loc.x, b.size.w) +} + +fn ranges_overlap(a_start: i32, a_size: i32, b_start: i32, b_size: i32) -> bool { + a_start < b_start + b_size.max(1) && a_start + a_size.max(1) > b_start +} + fn clamp_point_to_rect( location: Point, rect: Rectangle, ) -> Point { + const EDGE_EPSILON: f64 = 0.000_001; + let max_x = f64::from(rect.loc.x + rect.size.w.max(1)) - EDGE_EPSILON; + let max_y = f64::from(rect.loc.y + rect.size.h.max(1)) - EDGE_EPSILON; Point::from(( - location.x.clamp( - f64::from(rect.loc.x), - f64::from(rect.loc.x + rect.size.w.saturating_sub(1)), - ), - location.y.clamp( - f64::from(rect.loc.y), - f64::from(rect.loc.y + rect.size.h.saturating_sub(1)), - ), + location.x.clamp(f64::from(rect.loc.x), max_x), + location.y.clamp(f64::from(rect.loc.y), max_y), )) } @@ -402,6 +497,13 @@ fn squared_distance(a: Point, b: Point) -> f64 { dx * dx + dy * dy } +fn point_in_rect(point: Point, rect: Rectangle) -> bool { + point.x >= f64::from(rect.loc.x) + && point.y >= f64::from(rect.loc.y) + && point.x < f64::from(rect.loc.x + rect.size.w.max(1)) + && point.y < f64::from(rect.loc.y + rect.size.h.max(1)) +} + pub(super) fn relative_pointer_location( raw_location: Point, delta: Point, @@ -421,16 +523,67 @@ mod tests { use super::*; #[test] - fn relative_motion_continues_from_unclamped_location_after_visible_pointer_hits_edge() { + fn relative_motion_starts_from_visible_location_after_pointer_hits_edge() { let output_rects = [Rectangle::from_size((100, 100).into())]; let raw_location = Point::from((120.0, 50.0)); let visible_location = clamp_point_to_outputs(raw_location, &output_rects); - assert_eq!(visible_location, Point::from((99.0, 50.0))); - assert_eq!( - relative_pointer_location(raw_location, Point::from((20.0, 0.0))), - Point::from((140.0, 50.0)) + assert!((visible_location.x - 99.999_999).abs() < f64::EPSILON); + assert_eq!(visible_location.y, 50.0); + let relative = relative_pointer_location(visible_location, Point::from((20.0, 0.0))); + assert!((relative.x - 119.999_999).abs() < f64::EPSILON); + assert_eq!(relative.y, 50.0); + } + + #[test] + fn pointer_crosses_between_touching_outputs() { + let output_rects = [ + Rectangle::new((0, 0).into(), (100, 100).into()), + Rectangle::new((100, 0).into(), (100, 100).into()), + ]; + + let constrained = constrain_pointer_to_outputs( + Point::from((99.999_999, 50.0)), + Point::from((100.000_001, 50.0)), + &output_rects, + ); + + assert_eq!(constrained.visible, Point::from((100.000_001, 50.0))); + assert_eq!(constrained.raw, constrained.visible); + } + + #[test] + fn raw_pointer_does_not_accumulate_in_real_output_gaps() { + let output_rects = [ + Rectangle::new((0, 0).into(), (100, 100).into()), + Rectangle::new((120, 0).into(), (100, 100).into()), + ]; + + let constrained = constrain_pointer_to_outputs( + Point::from((99.999_999, 50.0)), + Point::from((110.0, 50.0)), + &output_rects, + ); + + assert_eq!(constrained.visible, Point::from((99.999_999, 50.0))); + assert_eq!(constrained.raw, constrained.visible); + } + + #[test] + fn pointer_does_not_jump_across_real_output_gaps() { + let output_rects = [ + Rectangle::new((0, 0).into(), (100, 100).into()), + Rectangle::new((120, 0).into(), (100, 100).into()), + ]; + + let constrained = constrain_pointer_to_outputs( + Point::from((99.999_999, 50.0)), + Point::from((130.0, 50.0)), + &output_rects, ); + + assert_eq!(constrained.visible, Point::from((99.999_999, 50.0))); + assert_eq!(constrained.raw, constrained.visible); } #[test] @@ -440,10 +593,9 @@ mod tests { Rectangle::new((2560, 0).into(), (1366, 768).into()), ]; - assert_eq!( - clamp_point_to_outputs(Point::from((3200.0, 1200.0)), &output_rects), - Point::from((3200.0, 767.0)) - ); + let clamped = clamp_point_to_outputs(Point::from((3200.0, 1200.0)), &output_rects); + assert_eq!(clamped.x, 3200.0); + assert!((clamped.y - 767.999_999).abs() < f64::EPSILON); } #[test] diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 486a586..dd355be 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -179,6 +179,7 @@ struct App { /// pointer-motion hit-testing and applied to the backend by the event loop /// (which owns the winit window, a sibling of this handler state). cursor_icon: CursorIcon, + cursor_visible: bool, next_spawn_position: CanvasPoint, next_spawn_output_name: Option, spawn_offset: i32, @@ -334,6 +335,7 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> exit_at: options.exit_after.map(|duration| Instant::now() + duration), full_redraw: 1, applied_cursor: CursorIcon::Default, + applied_cursor_visible: true, }; run_event_loop(event_loop, &mut data) @@ -483,6 +485,7 @@ pub(in crate::compositor) fn initialize_app( drag: None, resize: None, cursor_icon: CursorIcon::Default, + cursor_visible: true, next_spawn_position: CanvasPoint { x: 80, y: 96 }, next_spawn_output_name: None, spawn_offset: 0, diff --git a/crates/hearthspace-compositor/src/compositor/runtime.rs b/crates/hearthspace-compositor/src/compositor/runtime.rs index fe1c518..15d6cb0 100644 --- a/crates/hearthspace-compositor/src/compositor/runtime.rs +++ b/crates/hearthspace-compositor/src/compositor/runtime.rs @@ -49,6 +49,7 @@ pub(in crate::compositor) struct CalloopData { // Cursor icon currently applied to the winit window, so the desired cursor // (`state.cursor_icon`) is only pushed to the backend when it changes. pub(in crate::compositor) applied_cursor: CursorIcon, + pub(in crate::compositor) applied_cursor_visible: bool, } pub(in crate::compositor) fn create_headless_calloop_data( @@ -74,6 +75,7 @@ pub(in crate::compositor) fn create_headless_calloop_data( exit_at: exit_after.map(|duration| start_time + duration), full_redraw: 1, applied_cursor: CursorIcon::Default, + applied_cursor_visible: true, } } @@ -101,6 +103,7 @@ pub(in crate::compositor) fn create_calloop_data( exit_at: exit_after.map(|duration| start_time + duration), full_redraw: 1, applied_cursor: CursorIcon::Default, + applied_cursor_visible: true, } } @@ -195,13 +198,19 @@ impl CalloopData { /// Push the compositor's desired cursor to the host winit window, but only /// when it differs from the cursor currently shown. fn apply_cursor_icon(&mut self) { - if self.applied_cursor == self.state.cursor_icon { + if self.applied_cursor == self.state.cursor_icon + && self.applied_cursor_visible == self.state.cursor_visible + { return; } - trace!(from = ?self.applied_cursor, to = ?self.state.cursor_icon, "applying cursor icon"); + trace!(from = ?self.applied_cursor, to = ?self.state.cursor_icon, visible = self.state.cursor_visible, "applying cursor icon"); self.applied_cursor = self.state.cursor_icon; + self.applied_cursor_visible = self.state.cursor_visible; #[cfg(feature = "winit")] if let Backend::Winit(backend) = &self.backend { + backend + .window() + .set_cursor_visible(self.state.cursor_visible); backend.window().set_cursor(self.applied_cursor); } } diff --git a/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs index 027cf72..08b3b3c 100644 --- a/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs +++ b/crates/hearthspace-compositor/src/compositor/udev/device/cursor.rs @@ -32,6 +32,13 @@ impl UdevDevice { let Some(cursor) = self.hardware_cursor.as_mut() else { return; }; + if !state.cursor_visible { + if let Some(active_crtc) = self.hardware_cursor_crtc.take() { + clear_legacy_cursor(&self.scanout_node, active_crtc); + } + self.hardware_cursor_position = None; + return; + } let pointer = state.pointer_location; let target = self.output_surfaces.iter().find_map(|output| { let view = state.output_render_view(output.target.connector_name())?;