From 9c27976765c7a3ad8948366a8d3e6d9d267ff571 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Jul 2026 06:21:52 -0700 Subject: [PATCH] fix: mouse no longer leaves monitors --- .../src/compositor/input.rs | 9 + .../src/compositor/input/pointer.rs | 83 ++++------ .../src/compositor/output.rs | 24 +++ .../src/compositor/output/layout.rs | 156 +++++++++++++++++- crates/hearthspace-settings/src/monitors.rs | 18 +- 5 files changed, 226 insertions(+), 64 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs index fc67eb1..0fc6481 100644 --- a/crates/hearthspace-compositor/src/compositor/input.rs +++ b/crates/hearthspace-compositor/src/compositor/input.rs @@ -50,6 +50,15 @@ pub(in crate::compositor) fn handle_input_event( trace!(delta = ?event.delta(), time = event.time_msec(), "relative pointer motion input"); let scale = f64::from(state.output_scale_at(state.pointer_location).max(1)); let delta = Point::from((event.delta().x / scale, event.delta().y / scale)); + if scale != 1.0 { + debug!( + pointer = ?state.pointer_location, + raw_delta = ?event.delta(), + logical_delta = ?delta, + scale, + "scaled relative pointer motion by output scale" + ); + } state.apply_pointer_motion( relative_pointer_location(state.raw_pointer_location, delta), event.time_msec(), diff --git a/crates/hearthspace-compositor/src/compositor/input/pointer.rs b/crates/hearthspace-compositor/src/compositor/input/pointer.rs index 35407b4..107659f 100644 --- a/crates/hearthspace-compositor/src/compositor/input/pointer.rs +++ b/crates/hearthspace-compositor/src/compositor/input/pointer.rs @@ -212,8 +212,24 @@ impl App { pub(super) fn apply_pointer_motion(&mut self, location: Point, time: u32) { let previous_location = self.pointer_location; - let constrained = - constrain_pointer_to_outputs(previous_location, location, &self.output_logical_rects()); + let output_rects = self.output_logical_rects(); + let previous_output = rect_index_containing_point(previous_location, &output_rects); + let proposed_output = rect_index_containing_point(location, &output_rects); + let constrained = constrain_pointer_to_outputs(previous_location, location, &output_rects); + let visible_output = rect_index_containing_point(constrained.visible, &output_rects); + if constrained.visible != location || previous_output != visible_output { + debug!( + ?previous_location, + proposed = ?location, + visible = ?constrained.visible, + raw = ?constrained.raw, + ?previous_output, + ?proposed_output, + ?visible_output, + ?output_rects, + "resolved pointer motion against active output bounds" + ); + } self.pointer_location = constrained.visible; self.raw_pointer_location = constrained.raw; if self.software_cursor_visible && self.pointer_location != previous_location { @@ -395,23 +411,10 @@ fn constrain_pointer_to_outputs( 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); + if rect_containing_point(location, output_rects).is_some() { return PointerConstraint { - visible, - raw: visible, + visible: location, + raw: location, }; } @@ -434,6 +437,13 @@ fn rect_containing_point( .find(|rect| point_in_rect(point, *rect)) } +fn rect_index_containing_point( + point: Point, + rects: &[Rectangle], +) -> Option { + rects.iter().position(|rect| point_in_rect(point, *rect)) +} + fn nearest_rect_to_point( point: Point, rects: &[Rectangle], @@ -445,39 +455,6 @@ fn nearest_rect_to_point( }) } -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, @@ -570,7 +547,7 @@ mod tests { } #[test] - fn pointer_does_not_jump_across_real_output_gaps() { + fn pointer_accepts_locations_inside_any_output() { let output_rects = [ Rectangle::new((0, 0).into(), (100, 100).into()), Rectangle::new((120, 0).into(), (100, 100).into()), @@ -582,7 +559,7 @@ mod tests { &output_rects, ); - assert_eq!(constrained.visible, Point::from((99.999_999, 50.0))); + assert_eq!(constrained.visible, Point::from((130.0, 50.0))); assert_eq!(constrained.raw, constrained.visible); } diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index 467fa3f..9e43a28 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -123,6 +123,30 @@ impl OutputSet { let scale = plan.secondary_scale(&output.name).unwrap_or(output.scale); output.set_scale_and_location(scale, plan.secondary_location(&output.name)); } + debug!(outputs = ?self.output_debug(), "applied logical output layout"); + } + + fn output_debug( + &self, + ) -> Vec<( + String, + Point, + Size, + Size, + i32, + )> { + std::iter::once(&self.primary) + .chain(self.secondary.iter()) + .map(|output| { + ( + output.name.clone(), + output.location, + output.size, + output.logical_size(), + output.scale, + ) + }) + .collect() } fn logical_size(&self) -> Size { diff --git a/crates/hearthspace-compositor/src/compositor/output/layout.rs b/crates/hearthspace-compositor/src/compositor/output/layout.rs index c3e5ae4..9fb23c4 100644 --- a/crates/hearthspace-compositor/src/compositor/output/layout.rs +++ b/crates/hearthspace-compositor/src/compositor/output/layout.rs @@ -17,13 +17,23 @@ impl OutputLayout { &self, output_name: &str, role_id: &str, + ) -> Option> { + let use_role_fallback = !default_placeholder_monitors(&self.monitors); + self.monitor_for(output_name, role_id, use_role_fallback) + .map(|monitor| Point::from((monitor.x, monitor.y))) + } + + fn physical_location_for( + &self, + output_name: &str, + role_id: &str, ) -> Option> { let use_role_fallback = !default_placeholder_monitors(&self.monitors); self.monitor_for(output_name, role_id, use_role_fallback) .map(|monitor| { Point::from(( - logical_axis_position(&self.monitors, monitor.x, Axis::X), - logical_axis_position(&self.monitors, monitor.y, Axis::Y), + physical_axis_position_to_logical(&self.monitors, monitor.x, Axis::X), + physical_axis_position_to_logical(&self.monitors, monitor.y, Axis::Y), )) }) } @@ -56,7 +66,7 @@ enum Axis { Y, } -fn logical_axis_position( +fn physical_axis_position_to_logical( monitors: &[hearthspace_ipc::MonitorConfig], coordinate: i32, axis: Axis, @@ -228,6 +238,22 @@ pub(super) fn output_locations_for_layout( }); } + if !placements_connected(&placed) { + let physical_placed = output_placements_for_physical_positions( + fallback_primary.clone(), + fallback_secondary.clone(), + layout, + ); + if !placements_overlap(&physical_placed) && placements_connected(&physical_placed) { + debug!( + direct = ?placement_debug(&placed), + physical = ?placement_debug(&physical_placed), + "interpreting saved monitor positions as physical coordinates" + ); + placed = physical_placed; + } + } + if placements_overlap(&placed) { debug!(placements = ?placement_debug(&placed), "repairing overlapping active monitor layout"); placed = fallback_output_placements(fallback_primary, fallback_secondary); @@ -264,6 +290,58 @@ pub(super) fn output_locations_for_layout( } } +fn output_placements_for_physical_positions( + primary: OutputGeometry, + secondary: Vec, + layout: &OutputLayout, +) -> Vec { + let primary_location = layout + .physical_location_for(&primary.name, "primary") + .unwrap_or((0, 0).into()); + let mut placed = vec![OutputPlacement { + name: primary.name.clone(), + size: primary.logical_size(), + location: primary_location, + scale: primary.scale, + }]; + let mut unplaced = Vec::new(); + + for (index, output) in secondary.into_iter().enumerate() { + let role_id = secondary_role_id(index); + if let Some(location) = layout.physical_location_for(&output.name, &role_id) { + let size = output.logical_size(); + placed.push(OutputPlacement { + name: output.name, + size, + location, + scale: output.scale, + }); + } else { + unplaced.push(output); + } + } + + let fallback_y = primary_location.y; + let mut next_x = placed + .iter() + .map(|placement| placement.location.x + placement.size.w) + .max() + .unwrap_or(0); + for output in unplaced { + let size = output.logical_size(); + let location = Point::from((next_x, fallback_y)); + next_x += size.w; + placed.push(OutputPlacement { + name: output.name, + size, + location, + scale: output.scale, + }); + } + + placed +} + fn fallback_output_placements( primary: OutputGeometry, secondary: Vec, @@ -295,6 +373,47 @@ fn placements_overlap(placements: &[OutputPlacement]) -> bool { }) } +fn placements_connected(placements: &[OutputPlacement]) -> bool { + let Some(first) = placements.first() else { + return true; + }; + let mut connected = vec![first.name.clone()]; + let mut changed = true; + + while changed { + changed = false; + for placement in placements { + if connected.contains(&placement.name) { + continue; + } + let rect = Rectangle::new(placement.location, placement.size); + if placements.iter().any(|other| { + connected.contains(&other.name) + && rectangles_connected(rect, Rectangle::new(other.location, other.size)) + }) { + connected.push(placement.name.clone()); + changed = true; + } + } + } + + connected.len() == placements.len() +} + +fn rectangles_connected(a: Rectangle, b: Rectangle) -> bool { + rectangles_overlap(a, b) || rectangles_touch(a, b) || rectangles_touch(b, a) +} + +fn rectangles_touch(a: Rectangle, b: Rectangle) -> bool { + let horizontal_touch = a.loc.x + a.size.w == b.loc.x + && a.loc.y < b.loc.y + b.size.h + && a.loc.y + a.size.h > b.loc.y; + let vertical_touch = a.loc.y + a.size.h == b.loc.y + && a.loc.x < b.loc.x + b.size.w + && a.loc.x + a.size.w > b.loc.x; + horizontal_touch || vertical_touch +} + fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { a.loc.x < b.loc.x + b.size.w && a.loc.x + a.size.w > b.loc.x @@ -509,6 +628,37 @@ mod tests { #[test] fn output_layout_compacts_scaled_monitor_left_of_unscaled_monitor() { + let plan = output_locations_for_layout( + geometry_with_size("HDMI-A-1", 1920, 1080), + vec![geometry_with_size("eDP-1", 1920, 1080)], + &OutputLayout::from_monitors(vec![ + hearthspace_ipc::MonitorConfig { + id: "eDP-1".into(), + name: "eDP-1".into(), + width: 1920, + height: 1080, + scale: 2, + x: -960, + y: 0, + }, + hearthspace_ipc::MonitorConfig { + id: "HDMI-A-1".into(), + name: "HDMI-A-1".into(), + width: 1920, + height: 1080, + scale: 1, + x: 0, + y: 0, + }, + ]), + ); + + assert_eq!(plan.secondary_location("eDP-1"), Point::from((0, 0))); + assert_eq!(plan.primary, Point::from((960, 0))); + } + + #[test] + fn output_layout_accepts_old_physical_positions_for_scaled_outputs() { let plan = output_locations_for_layout( geometry_with_size("HDMI-A-1", 1920, 1080), vec![geometry_with_size("eDP-1", 1920, 1080)], diff --git a/crates/hearthspace-settings/src/monitors.rs b/crates/hearthspace-settings/src/monitors.rs index b273723..9a155af 100644 --- a/crates/hearthspace-settings/src/monitors.rs +++ b/crates/hearthspace-settings/src/monitors.rs @@ -136,8 +136,10 @@ pub(crate) fn monitor_logical_height(monitor: &MonitorConfig) -> i32 { logical_dimension(monitor.height, monitor.scale) } -fn logical_dimension(size: i32, _scale: i32) -> i32 { - size.max(1) +fn logical_dimension(size: i32, scale: i32) -> i32 { + let size = size.max(1); + let scale = scale.max(1); + ((size + scale - 1) / scale).max(1) } fn default_placeholder_monitors(monitors: &[MonitorConfig]) -> bool { @@ -305,11 +307,11 @@ mod tests { ); assert_eq!(monitors[0].scale, 2); - assert_eq!(monitor_logical_width(&monitors[0]), 2560); + assert_eq!(monitor_logical_width(&monitors[0]), 1280); } #[test] - fn scaling_monitor_keeps_outputs_at_physical_desktop_size() { + fn scaling_monitor_moves_neighbors_to_scaled_logical_edge() { let mut monitors = Settings::default().monitors; recalculate_monitor_positions_for_scale(&mut monitors, "primary", 2); @@ -323,13 +325,13 @@ mod tests { .find(|monitor| monitor.id == "secondary") .unwrap(); assert_eq!(primary.scale, 2); - assert_eq!(monitor_logical_width(primary), 1920); - assert_eq!((secondary.x, secondary.y), (1920, 0)); + assert_eq!(monitor_logical_width(primary), 960); + assert_eq!((secondary.x, secondary.y), (960, 0)); assert!(!monitors_overlap(&monitors)); } #[test] - fn scaling_monitor_keeps_outputs_below_at_physical_desktop_size() { + fn scaling_monitor_moves_outputs_below_to_scaled_logical_edge() { let mut monitors = Settings::default().monitors; offset_monitor(&mut monitors, "secondary", 0, 1080); @@ -339,7 +341,7 @@ mod tests { .iter() .find(|monitor| monitor.id == "secondary") .unwrap(); - assert_eq!((secondary.x, secondary.y), (0, 1080)); + assert_eq!((secondary.x, secondary.y), (0, 540)); assert!(!monitors_overlap(&monitors)); } } -- 2.51.2