From f9b8622d8a5f3542994e1255949223ee2e0fc182 Mon Sep 17 00:00:00 2001 From: Giovanni Smith-Alvarez Date: Sun, 25 Oct 2026 11:42:22 -0400 Subject: [PATCH 1/2] fix(input): allow unconstrained selection translation on pannable boards and sync drag anchor - Pannable boards (solid boards) have unbounded canvases; do not clamp selection translation against the viewport rectangle. - Retain clamping on transparent overlay boards where the canvas is bound to the output. - Always advance MovingSelection last_x/last_y on pointer motion so dragging against a boundary does not leave a stale reference anchor that causes jumping. --- .../selection_actions/translation/bounds.rs | 27 +++---- .../interaction/adapters/active_motion.rs | 20 +++--- src/input/state/tests/transform.rs | 70 ++++++++++++++----- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 0c027215..774e5d08 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -105,26 +105,27 @@ impl InputState { dx: i32, dy: i32, ) -> Option<(i32, i32)> { + // Pannable boards (Blackboard, Whiteboard, etc.) have an unbounded canvas; + // clamping against the screen viewport creates artificial boundaries + // ("invisible walls") and breaks dragging across the canvas. + if self.boards.pan_enabled() && !self.board_is_transparent() { + return Some((dx, dy)); + } + let bounds = self.movable_selection_bounds(measurer)?; let (screen_width, screen_height) = self.view.screen_size(); - // Shape bounds are canvas coordinates, so the limit has to be the - // visible canvas area: board pan and zoom both move the screen rect - // away from the canvas origin. Without a known screen size there is - // nothing to clamp against. - let visible = self.view.visible_canvas_rect(self.board_view_offset()); - let (start_x, start_y) = (visible.x, visible.y); - let end_x = visible.x.saturating_add(visible.width); - let end_y = visible.y.saturating_add(visible.height); - - let clamped_dx = if dx == 0 || screen_width == 0 { + let screen_width = screen_width.min(i32::MAX as u32) as i32; + let screen_height = screen_height.min(i32::MAX as u32) as i32; + + let clamped_dx = if dx == 0 || screen_width <= 0 { dx } else { - Self::clamp_axis_delta(bounds.x, bounds.width, start_x, end_x, dx) + Self::clamp_axis_delta(bounds.x, bounds.width, 0, screen_width, dx) }; - let clamped_dy = if dy == 0 || screen_height == 0 { + let clamped_dy = if dy == 0 || screen_height <= 0 { dy } else { - Self::clamp_axis_delta(bounds.y, bounds.height, start_y, end_y, dy) + Self::clamp_axis_delta(bounds.y, bounds.height, 0, screen_height, dy) }; Some((clamped_dx, clamped_dy)) diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index 2db2597c..e2c6f64d 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -110,19 +110,21 @@ pub(crate) fn handle_active_motion( let dragging = grab.track(canvas.x(), canvas.y()); let dx = canvas.x() - *last_x; let dy = canvas.y() - *last_y; - if dragging + let translated = dragging && (dx != 0 || dy != 0) - && state.apply_translation_to_selection_with(measurer, dx, dy) - && let DrawingState::MovingSelection { - last_x, - last_y, - moved, - .. - } = &mut state.state + && state.apply_translation_to_selection_with(measurer, dx, dy); + if let DrawingState::MovingSelection { + last_x, + last_y, + moved, + .. + } = &mut state.state { *last_x = canvas.x(); *last_y = canvas.y(); - *moved = true; + if translated { + *moved = true; + } } return Some(RoutingOutcome::Continued( ActiveInteractionKind::MovingSelection, diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 6abf7a1f..c7695686 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -1,8 +1,8 @@ +use super::*; use crate::draw::{ArrowStyle, TextMeasurer}; +use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT}; use crate::ui_text::UiTextEngine; -use super::*; - #[test] fn translate_selection_with_undo_moves_shape() { let route_measurer = TextMeasurer::default(); @@ -565,10 +565,10 @@ fn stretching_a_flat_curved_arrow_downward_grows_its_arc() { #[test] fn moving_a_selection_on_a_panned_board_follows_the_pointer() { - let measurer = crate::draw::TextMeasurer::default(); + let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); state.update_screen_dimensions(1920, 1080); - assert!(state.boards.switch_to_id(crate::input::BOARD_ID_BLACKBOARD)); + assert!(state.boards.switch_to_id(BOARD_ID_BLACKBOARD)); // Screen (0, 0) is canvas (-3306, 93) on this board. state.boards.active_frame_mut().set_view_offset(-3306, 93); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -577,6 +577,7 @@ fn moving_a_selection_on_a_panned_board_follows_the_pointer() { w: 480, h: 100, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -607,21 +608,19 @@ fn moving_a_selection_on_a_panned_board_follows_the_pointer() { } #[test] -fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { - let measurer = crate::draw::TextMeasurer::default(); +fn moving_a_selection_on_a_panned_board_can_move_freely_across_canvas() { + let measurer = TextMeasurer::default(); let mut state = create_test_input_state(); state.update_screen_dimensions(1920, 1080); - assert!(state.boards.switch_to_id(crate::input::BOARD_ID_BLACKBOARD)); - state.view.set_zoom_status(true, false, 4.0, (0.0, 0.0)); - // At 4x the visible canvas is 480x270. The 400px-wide selection carries - // ~2px of line-width bounds padding, so it may slide 78px before its right - // edge reaches the visible canvas edge. + assert!(state.boards.switch_to_id(BOARD_ID_BLACKBOARD)); + state.boards.active_frame_mut().set_view_offset(-1000, 500); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { - x: 0, - y: 0, - w: 400, + x: -500, + y: 600, + w: 200, h: 100, fill: false, + fill_color: None, color: state.style.current_color, thick: state.style.current_thickness, }); @@ -634,6 +633,43 @@ fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { .unwrap() .bounding_box() .expect("rect should have bounds"); + + // On an infinite pannable board, dragging across or past the screen area must + // not be blocked by an artificial viewport clamp ("invisible wall"). + assert!(state.apply_translation_to_selection_with(&measurer, 2500, 1500)); + + let after = state + .boards + .active_frame() + .shape(shape_id) + .unwrap() + .bounding_box() + .expect("rect should have bounds"); + + assert_eq!(after.x - before.x, 2500); + assert_eq!(after.y - before.y, 1500); +} + +#[test] +fn moving_a_selection_on_transparent_board_clamps_to_screen() { + let measurer = TextMeasurer::default(); + let mut state = create_test_input_state(); + state.update_screen_dimensions(1920, 1080); + assert!(state.boards.switch_to_id(BOARD_ID_TRANSPARENT)); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 1800, + y: 100, + w: 100, + h: 100, + fill: false, + fill_color: None, + color: state.style.current_color, + thick: state.style.current_thickness, + }); + state.set_selection(vec![shape_id]); + + // Transparent overlay is bounded by screen (1920x1080). + // A 100px-wide shape at x=1800 can only slide to x=1820 (with bounds padding) before hitting screen edge. state.apply_translation_to_selection_with(&measurer, 300, 0); let after = state .boards @@ -643,9 +679,5 @@ fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { .bounding_box() .expect("rect should have bounds"); - assert_eq!( - after.x - before.x, - 78, - "the selection must stop at the visible canvas edge (480 canvas px at 4x), not at the screen width" - ); + assert!(after.x + after.width <= 1920); } From da68d152ac1f209d57157c85e193203a1c42f786 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:34:25 +0100 Subject: [PATCH 2/2] fix(input): keep selection movement stable across canvas bounds Keep the press as the drag anchor until the pointer leaves the click radius, so the first drag motion is not lost. After that the anchor follows the pointer on every motion, including blocked ones, so reversing at an edge moves the selection at once. Apply coordinate limits on every board. Pannable boards still move freely, but each translated coordinate and the painted bounds now stay in the i32 range. Before, a nudge near i32::MAX wrapped in release builds: the line jumped to span the whole coordinate range and was saved that way. Debug builds panicked instead. On a fixed canvas (the transparent overlay, or a board with panning disabled), a selection that already crosses an edge, or lies outside the canvas, can no longer move further out, and it is never pulled back. Before, any nudge snapped it inside, up to thousands of pixels. Align the move-to-edge actions with the visible canvas, including pan and zoom, instead of the screen origin. A target further away than i32::MAX is reached in several steps that record one undo entry. Combine selection bounds in i64. Shape bounds are clamped to the i32 range, so a shape at the coordinate limit is measured on a copy moved toward the origin; the limits and edge actions then see its full stroke padding. A selection whose painted bounds end past i32::MAX gets no selection Rect, because handle hit-testing and other selection code compute x + width in i32. Its chrome then marks full damage, so the halos of its other members are still repainted. The Properties panel uses the same bounds, the text resize handle is omitted when it would end past i32::MAX, and handle hit-testing compares pointer distances in i64. Test changes: - nudge_selection_clamps_right_and_bottom_edges starts at (87, 87). At (90, 90) the stroke padding already crossed the canvas edge, and the test passed only because the old clamp pulled the shape back. - moving_a_selection_on_transparent_board_clamps_to_screen checks the exact landing edge. It also passed when nothing moved. The coordinate guard and the view-relative edge targets are adapted from #424. --- src/draw/shape/transform.rs | 37 + src/input/state/core/history.rs | 4 +- src/input/state/core/selection.rs | 78 +- .../state/core/selection_actions/delete.rs | 4 +- .../resize/resize_helpers.rs | 3 +- .../core/selection_actions/text/handles.rs | 22 +- .../selection_actions/translation/bounds.rs | 321 +++-- .../core/selection_actions/translation/mod.rs | 100 +- .../interaction/adapters/active_motion.rs | 15 +- src/input/state/mod.rs | 5 + src/input/state/tests/selection/damage.rs | 34 + src/input/state/tests/selection/mod.rs | 1 + src/input/state/tests/selection/movement.rs | 1145 +++++++++++++++++ src/input/state/tests/transform.rs | 21 +- 14 files changed, 1606 insertions(+), 184 deletions(-) create mode 100644 src/input/state/tests/selection/movement.rs diff --git a/src/draw/shape/transform.rs b/src/draw/shape/transform.rs index 0b6a5a31..a54a3b53 100644 --- a/src/draw/shape/transform.rs +++ b/src/draw/shape/transform.rs @@ -5,6 +5,43 @@ use crate::util; use super::Shape; impl Shape { + /// Limits movement so every translated integer coordinate stays representable. + pub(crate) fn constrain_translation(&self, dx: i32, dy: i32) -> (i32, i32) { + let constrain = |position: i32, delta: i32| { + let position = i64::from(position); + let clamped = i64::from(delta).clamp( + i64::from(i32::MIN) - position, + i64::from(i32::MAX) - position, + ); + i32::try_from(clamped) + .expect("anchor-constrained movement lies between zero and the requested i32 delta") + }; + + match self { + Shape::Line { x1, y1, x2, y2, .. } | Shape::Arrow { x1, y1, x2, y2, .. } => ( + constrain(*x2, constrain(*x1, dx)), + constrain(*y2, constrain(*y1, dy)), + ), + Shape::Rect { x, y, .. } + | Shape::BlurRect { x, y, .. } + | Shape::Image { x, y, .. } + | Shape::Text { x, y, .. } + | Shape::StepMarker { x, y, .. } + | Shape::StickyNote { x, y, .. } => (constrain(*x, dx), constrain(*y, dy)), + Shape::Ellipse { cx, cy, .. } | Shape::Spotlight { cx, cy, .. } => { + (constrain(*cx, dx), constrain(*cy, dy)) + } + Shape::Polygon { points, .. } | Shape::EraserStroke { points, .. } => { + points.iter().fold((dx, dy), |(dx, dy), &(x, y)| { + (constrain(x, dx), constrain(y, dy)) + }) + } + Shape::Freehand { .. } + | Shape::FreehandPressure { .. } + | Shape::MarkerStroke { .. } => (dx, dy), + } + } + pub(crate) fn translate(&mut self, dx: i32, dy: i32) { match self { Shape::Freehand { points, .. } => { diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 31c46ddf..e20df613 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -31,12 +31,12 @@ impl InputState { // A live slider drag holds snapshots from before it began; it has to // land as its own entry before history moves past it. self.finish_properties_slider_drag_with(measurer); - let selection_chrome = self.selection_chrome_bounds_with(measurer); + let selection_chrome = self.selection_chrome_damage_with(measurer); let Some(action) = step(self.boards.active_frame_mut()) else { return false; }; - self.mark_selection_dirty_region(selection_chrome); + self.mark_selection_chrome_damage(selection_chrome); self.apply_action_side_effects_with(measurer, &action); // A recognition chip advertises this undo; once history moves it no // longer describes the canvas. diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index 8aa107f1..4d792802 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -9,6 +9,16 @@ use crate::util::Rect; use std::collections::HashSet; use std::time::Instant; +/// What to repaint for a selection's chrome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SelectionChromeDamage { + /// The chrome's canvas area, if anything selected is painted. + Region(Option), + /// The painted bounds do not fit a [`Rect`], for example because they + /// end past `i32::MAX`, yet every member still paints a halo. + Everything, +} + #[derive(Debug, Clone, Default)] enum SelectionState { #[default] @@ -201,65 +211,55 @@ impl InputState { change: impl FnOnce(&mut SelectionInteraction), ) { let previous_ids = self.selected_shape_ids().to_vec(); - let previous_chrome = self.selection_chrome_bounds_with(measurer); + let previous_chrome = self.selection_chrome_damage_with(measurer); change(&mut self.selection_interaction); self.close_properties_panel(); if self.selected_shape_ids() != previous_ids.as_slice() { - self.mark_selection_dirty_region(previous_chrome); + self.mark_selection_chrome_damage(previous_chrome); self.mark_selection_chrome_dirty_with(measurer); self.needs_redraw = true; } } - /// Canvas bounds of everything painted for the selection: the halos, the + /// Canvas area of everything painted for the selection: the halos, the /// dashed handle frame, and the resize handle of a lone text shape, which /// sits outside the shape. - pub(crate) fn selection_chrome_bounds_with(&self, measurer: &TextMeasurer) -> Option { - let bounds = self.selection_bounds_with(measurer)?; - match self.selected_text_resize_handle_with(measurer) { + pub(crate) fn selection_chrome_damage_with( + &self, + measurer: &TextMeasurer, + ) -> SelectionChromeDamage { + let Some(bounds) = self.selection_bounds_with(measurer) else { + return if self.selection_is_painted_with(measurer) { + SelectionChromeDamage::Everything + } else { + SelectionChromeDamage::Region(None) + }; + }; + + let chrome = match self.selected_text_resize_handle_with(measurer) { Some((_, handle)) => bounds.union(handle), None => Some(bounds), + }; + SelectionChromeDamage::Region(chrome) + } + + pub(crate) fn mark_selection_chrome_damage(&mut self, damage: SelectionChromeDamage) { + match damage { + SelectionChromeDamage::Region(chrome) => self.mark_selection_dirty_region(chrome), + SelectionChromeDamage::Everything => { + self.dirty_tracker.mark_full(); + self.mark_selection_dirty_region(None); + } } } /// Repaints the current selection chrome. Also used when an interaction /// ends and the handles it hid reappear without a membership change. pub(crate) fn mark_selection_chrome_dirty_with(&mut self, measurer: &TextMeasurer) { - let chrome = self.selection_chrome_bounds_with(measurer); - self.mark_selection_dirty_region(chrome); - } - - pub(crate) fn selection_bounding_box_with( - &self, - measurer: &TextMeasurer, - ids: &[ShapeId], - ) -> Option { - let frame = self.boards.active_frame(); - let mut min_x = i32::MAX; - let mut min_y = i32::MAX; - let mut max_x = i32::MIN; - let mut max_y = i32::MIN; - let mut found = false; - - for id in ids { - if let Some(shape) = frame.shape(*id) - && let Some(bounds) = shape.bounding_box_with(measurer) - { - min_x = min_x.min(bounds.x); - min_y = min_y.min(bounds.y); - max_x = max_x.max(bounds.x + bounds.width); - max_y = max_y.max(bounds.y + bounds.height); - found = true; - } - } - - if found { - Rect::from_min_max(min_x, min_y, max_x, max_y) - } else { - None - } + let chrome = self.selection_chrome_damage_with(measurer); + self.mark_selection_chrome_damage(chrome); } pub(crate) fn selection_screen_bounding_box_with( diff --git a/src/input/state/core/selection_actions/delete.rs b/src/input/state/core/selection_actions/delete.rs index 10a7963a..53ac9195 100644 --- a/src/input/state/core/selection_actions/delete.rs +++ b/src/input/state/core/selection_actions/delete.rs @@ -39,7 +39,7 @@ impl InputState { // Measured before the shapes go: the chrome of a deleted text shape // reaches past the shape to its resize handle. - let selection_chrome = self.selection_chrome_bounds_with(measurer); + let selection_chrome = self.selection_chrome_damage_with(measurer); let effects = crate::input::state::core::editing::CanvasEdit::delete( self.boards.active_frame_mut(), id_set, @@ -50,7 +50,7 @@ impl InputState { return false; } - self.mark_selection_dirty_region(selection_chrome); + self.mark_selection_chrome_damage(selection_chrome); self.clear_selection_with(measurer); true } diff --git a/src/input/state/core/selection_actions/resize/resize_helpers.rs b/src/input/state/core/selection_actions/resize/resize_helpers.rs index 0fddcc45..3f3ecced 100644 --- a/src/input/state/core/selection_actions/resize/resize_helpers.rs +++ b/src/input/state/core/selection_actions/resize/resize_helpers.rs @@ -14,7 +14,8 @@ const MIN_RESIZE_SIZE: f64 = 10.0; impl InputState { pub(super) fn point_near(&self, x: i32, y: i32, cx: i32, cy: i32, radius: i32) -> bool { - (x - cx).abs() <= radius && (y - cy).abs() <= radius + let near = |a: i32, b: i32| (i64::from(a) - i64::from(b)).abs() <= i64::from(radius); + near(x, cx) && near(y, cy) } pub(super) fn selection_handle_probes( diff --git a/src/input/state/core/selection_actions/text/handles.rs b/src/input/state/core/selection_actions/text/handles.rs index 6c31a99c..234e3d28 100644 --- a/src/input/state/core/selection_actions/text/handles.rs +++ b/src/input/state/core/selection_actions/text/handles.rs @@ -6,12 +6,24 @@ const TEXT_RESIZE_HANDLE_SIZE: i32 = 10; const TEXT_RESIZE_HANDLE_OFFSET: i32 = 6; impl InputState { + /// Returns no handle when it would end past `i32::MAX`, which text + /// placed at the coordinate limit can reach. fn text_resize_handle_rect(bounds: Rect) -> Option { - let size = TEXT_RESIZE_HANDLE_SIZE; - let half = size / 2; - let center_x = bounds.x + bounds.width + TEXT_RESIZE_HANDLE_OFFSET; - let center_y = bounds.y + bounds.height + TEXT_RESIZE_HANDLE_OFFSET; - Rect::new(center_x - half, center_y - half, size, size) + let size = i64::from(TEXT_RESIZE_HANDLE_SIZE); + let inset = i64::from(TEXT_RESIZE_HANDLE_OFFSET) - size / 2; + let x = i64::from(bounds.x) + i64::from(bounds.width) + inset; + let y = i64::from(bounds.y) + i64::from(bounds.height) + inset; + let limit = i64::from(i32::MAX); + if x + size > limit || y + size > limit { + return None; + } + + Rect::new( + i32::try_from(x).ok()?, + i32::try_from(y).ok()?, + TEXT_RESIZE_HANDLE_SIZE, + TEXT_RESIZE_HANDLE_SIZE, + ) } pub(crate) fn selected_text_resize_handle_with( diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 774e5d08..31000339 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -1,133 +1,256 @@ -use crate::draw::TextMeasurer; +use crate::draw::{DrawnShape, ShapeId, TextMeasurer}; use crate::input::InputState; use crate::util::Rect; -impl InputState { - /// Returns the combined bounding box of all selected shapes (public for rendering). - pub fn selection_bounds(&self) -> Option { - let measurer = TextMeasurer::default(); - self.selection_bounds_with(&measurer) +/// Combined painted bounds with exclusive maxima. +/// +/// The edges are widened because a shape's painted bounds can end past +/// `i32::MAX`, and a union of distant shapes can be wider than [`Rect`] holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SelectionExtent { + pub(super) min_x: i64, + pub(super) min_y: i64, + pub(super) max_x: i64, + pub(super) max_y: i64, +} + +impl SelectionExtent { + fn combine<'frame>( + shapes: impl Iterator, + measurer: &TextMeasurer, + ) -> Option { + shapes + .filter_map(|drawn| Self::of_shape(drawn, measurer)) + .reduce(Self::union) } - /// Combined selection bounds using the supplied text measurement owner. - pub fn selection_bounds_with(&self, measurer: &TextMeasurer) -> Option { - let ids = self.selected_shape_ids(); - if ids.is_empty() { - return None; + /// Painted bounds of one shape. + /// + /// Shape bounds are clamped to the `i32` range, so a shape at the + /// coordinate limit is measured on a copy moved toward the origin. Its + /// painted size does not depend on where it is. + fn of_shape(drawn: &DrawnShape, measurer: &TextMeasurer) -> Option { + let extent = Self::from_rect(drawn.bounding_box_with(measurer)?); + if !extent.touches_coordinate_limit() { + return Some(extent); } - let frame = self.boards.active_frame(); - let mut min_x = i32::MAX; - let mut min_y = i32::MAX; - let mut max_x = i32::MIN; - let mut max_y = i32::MIN; - let mut found = false; - - for id in ids { - if let Some(shape) = frame.shape(*id) - && let Some(bounds) = shape.bounding_box_with(measurer) - { - min_x = min_x.min(bounds.x); - min_y = min_y.min(bounds.y); - max_x = max_x.max(bounds.x + bounds.width); - max_y = max_y.max(bounds.y + bounds.height); - found = true; - } + let (dx, dy) = drawn.shape.constrain_translation( + saturating_delta(-(extent.min_x + extent.max_x) / 2), + saturating_delta(-(extent.min_y + extent.max_y) / 2), + ); + let mut copy = drawn.shape.clone(); + copy.translate(dx, dy); + let Some(moved) = copy.bounding_box_with(measurer) else { + return Some(extent); + }; + let moved = Self::from_rect(moved); + let (dx, dy) = (i64::from(dx), i64::from(dy)); + Some(Self { + min_x: moved.min_x - dx, + min_y: moved.min_y - dy, + max_x: moved.max_x - dx, + max_y: moved.max_y - dy, + }) + } + + fn touches_coordinate_limit(self) -> bool { + let min = i64::from(i32::MIN); + let max = i64::from(i32::MAX); + self.min_x <= min || self.min_y <= min || self.max_x > max || self.max_y > max + } + + fn from_rect(rect: Rect) -> Self { + let min_x = i64::from(rect.x); + let min_y = i64::from(rect.y); + Self { + min_x, + min_y, + max_x: min_x + i64::from(rect.width), + max_y: min_y + i64::from(rect.height), } + } - if found { - Rect::from_min_max(min_x, min_y, max_x, max_y) - } else { - None + fn union(self, other: Self) -> Self { + Self { + min_x: self.min_x.min(other.min_x), + min_y: self.min_y.min(other.min_y), + max_x: self.max_x.max(other.max_x), + max_y: self.max_y.max(other.max_y), } } - pub(super) fn movable_selection_bounds(&self, measurer: &TextMeasurer) -> Option { - let ids = self.selected_shape_ids(); - if ids.is_empty() { + /// Returns no [`Rect`] for bounds whose far edge ends past `i32::MAX`, + /// because handle hit-testing and other selection code compute + /// `x + width` in `i32`. + fn to_rect(self) -> Option { + let limit = i64::from(i32::MAX); + if self.max_x > limit || self.max_y > limit { return None; } - let frame = self.boards.active_frame(); - let mut min_x = i32::MAX; - let mut min_y = i32::MAX; - let mut max_x = i32::MIN; - let mut max_y = i32::MIN; - let mut found = false; - - for id in ids { - if let Some(shape) = frame.shape(*id) { - if shape.locked { - continue; - } - if let Some(bounds) = shape.bounding_box_with(measurer) { - min_x = min_x.min(bounds.x); - min_y = min_y.min(bounds.y); - max_x = max_x.max(bounds.x + bounds.width); - max_y = max_y.max(bounds.y + bounds.height); - found = true; - } - } - } + let x = i32::try_from(self.min_x).ok()?; + let y = i32::try_from(self.min_y).ok()?; + let width = i32::try_from(self.width()).ok()?; + let height = i32::try_from(self.height()).ok()?; + Rect::new(x, y, width, height) + } - if found { - Rect::from_min_max(min_x, min_y, max_x, max_y) - } else { - None - } + pub(super) fn width(self) -> i64 { + self.max_x - self.min_x } - fn clamp_axis_delta(position: i32, size: i32, start: i32, end_limit: i32, delta: i32) -> i32 { - if end_limit <= start || size <= 0 { - return delta; - } + pub(super) fn height(self) -> i64 { + self.max_y - self.min_y + } +} - let extent = end_limit.saturating_sub(start); - let end = position.saturating_add(size); - let (min_delta, max_delta) = if size <= extent { - ( - start.saturating_sub(position), - end_limit.saturating_sub(end), - ) - } else { - ( - end_limit.saturating_sub(end), - start.saturating_sub(position), - ) - }; +impl InputState { + /// Returns the combined bounding box of all selected shapes (public for rendering). + pub fn selection_bounds(&self) -> Option { + let measurer = TextMeasurer::default(); + self.selection_bounds_with(&measurer) + } + + /// Combined selection bounds using the supplied text measurement owner. + pub fn selection_bounds_with(&self, measurer: &TextMeasurer) -> Option { + self.selection_bounding_box_with(measurer, self.selected_shape_ids()) + } + + /// Combined painted bounds of `ids`, or `None` when nothing is painted + /// or the bounds do not fit a [`Rect`]. + pub(crate) fn selection_bounding_box_with( + &self, + measurer: &TextMeasurer, + ids: &[ShapeId], + ) -> Option { + self.painted_extent_with(measurer, ids)?.to_rect() + } - delta.clamp(min_delta, max_delta) + /// Whether any selected shape is painted, including when the combined + /// bounds are too large for [`InputState::selection_bounds_with`]. + pub(crate) fn selection_is_painted_with(&self, measurer: &TextMeasurer) -> bool { + self.painted_extent_with(measurer, self.selected_shape_ids()) + .is_some() + } + + fn painted_extent_with( + &self, + measurer: &TextMeasurer, + ids: &[ShapeId], + ) -> Option { + let frame = self.boards.active_frame(); + let shapes = ids.iter().filter_map(|id| frame.shape(*id)); + SelectionExtent::combine(shapes, measurer) + } + + /// Combined painted bounds of the selected shapes that a move can change. + pub(super) fn movable_selection_extent( + &self, + measurer: &TextMeasurer, + ) -> Option { + let frame = self.boards.active_frame(); + let shapes = self + .selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .filter(|drawn| !drawn.locked); + SelectionExtent::combine(shapes, measurer) } + /// Limits a requested selection move to one delta shared by every + /// unlocked selected shape. + /// + /// A pannable solid board has an unbounded canvas, so the viewport is not + /// a movement boundary there. The transparent overlay, and a solid board + /// with panning disabled, keep a fixed canvas the size of the screen. On + /// every board the move is then shortened so that each translated + /// coordinate and the painted bounds stay representable. Limits only + /// shorten a move; they never reverse it. pub(super) fn clamp_selection_translation( &self, measurer: &TextMeasurer, dx: i32, dy: i32, ) -> Option<(i32, i32)> { - // Pannable boards (Blackboard, Whiteboard, etc.) have an unbounded canvas; - // clamping against the screen viewport creates artificial boundaries - // ("invisible walls") and breaks dragging across the canvas. - if self.boards.pan_enabled() && !self.board_is_transparent() { - return Some((dx, dy)); - } - - let bounds = self.movable_selection_bounds(measurer)?; - let (screen_width, screen_height) = self.view.screen_size(); - let screen_width = screen_width.min(i32::MAX as u32) as i32; - let screen_height = screen_height.min(i32::MAX as u32) as i32; - - let clamped_dx = if dx == 0 || screen_width <= 0 { - dx + let extent = self.movable_selection_extent(measurer); + let (dx, dy) = if self.boards.pan_enabled() && !self.board_is_transparent() { + (dx, dy) } else { - Self::clamp_axis_delta(bounds.x, bounds.width, 0, screen_width, dx) + let extent = extent?; + let (screen_width, screen_height) = self.view.screen_size(); + // An unknown screen dimension leaves that axis unclamped. + let dx = if screen_width == 0 { + dx + } else { + clamp_delta( + dx, + fixed_canvas_range(extent.min_x, extent.max_x, i64::from(screen_width)), + ) + }; + let dy = if screen_height == 0 { + dy + } else { + clamp_delta( + dy, + fixed_canvas_range(extent.min_y, extent.max_y, i64::from(screen_height)), + ) + }; + (dx, dy) }; - let clamped_dy = if dy == 0 || screen_height <= 0 { - dy - } else { - Self::clamp_axis_delta(bounds.y, bounds.height, 0, screen_height, dy) + + let frame = self.boards.active_frame(); + let unlocked = self + .selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .filter(|drawn| !drawn.locked); + let (dx, dy) = unlocked.fold((dx, dy), |(dx, dy), drawn| { + drawn.shape.constrain_translation(dx, dy) + }); + + // Keep the painted bounds inside the i32 range too, because selection + // code reads them back as a `Rect`. + let Some(extent) = extent else { + return Some((dx, dy)); }; + let min = i64::from(i32::MIN); + let max = i64::from(i32::MAX); + Some(( + clamp_delta(dx, (min - extent.min_x, max - extent.max_x)), + clamp_delta(dy, (min - extent.min_y, max - extent.max_y)), + )) + } +} - Some((clamped_dx, clamped_dy)) +/// Saturates a wide delta to one `i32` step. +pub(super) fn saturating_delta(delta: i64) -> i32 { + i32::try_from(delta).unwrap_or(if delta.is_negative() { + i32::MIN + } else { + i32::MAX + }) +} + +/// Movement range along one axis of a fixed canvas `0..canvas`. +/// +/// A selection that fits may move until it touches either edge; a wider one +/// may slide while it still covers the canvas. +fn fixed_canvas_range(min: i64, max: i64, canvas: i64) -> (i64, i64) { + if max - min <= canvas { + (-min, canvas - max) + } else { + (canvas - max, -min) } } + +/// Clamps `delta` to `low..=high` after widening that range to contain zero. +/// +/// A selection that already crosses a limit, or lies entirely past it, +/// therefore cannot move further out, but it is never pulled back against the +/// requested direction. +fn clamp_delta(delta: i32, (low, high): (i64, i64)) -> i32 { + let clamped = i64::from(delta).clamp(low.min(0), high.max(0)); + i32::try_from(clamped) + .expect("a range containing zero keeps the delta between zero and its i32 value") +} diff --git a/src/input/state/core/selection_actions/translation/mod.rs b/src/input/state/core/selection_actions/translation/mod.rs index 92ae6fb7..cf312539 100644 --- a/src/input/state/core/selection_actions/translation/mod.rs +++ b/src/input/state/core/selection_actions/translation/mod.rs @@ -2,10 +2,17 @@ use crate::draw::frame::ShapeSnapshot; use crate::draw::{ShapeId, TextMeasurer}; use crate::input::InputState; +use bounds::SelectionExtent; + mod bounds; mod restore; mod undo; +/// Steps an edge action may take. The delta from a selection edge to a +/// visible edge spans at most three saturated `i32` steps, plus one if a +/// limit shortened a step. +const MAX_ALIGNMENT_STEPS: usize = 4; + impl InputState { pub(crate) fn capture_movable_selection_snapshots(&self) -> Vec<(ShapeId, ShapeSnapshot)> { crate::input::state::core::editing::CanvasEdit::capture( @@ -74,24 +81,23 @@ impl InputState { measurer: &TextMeasurer, to_start: bool, ) -> bool { - let Some(bounds) = self.movable_selection_bounds(measurer) else { - return false; - }; - let screen_width = self.view.screen_width().min(i32::MAX as u32) as i32; - if screen_width <= 0 { + if self.view.screen_width() == 0 { return false; } - let target_x = if to_start { - 0 - } else { - screen_width - bounds.width - }; - let dx = target_x - bounds.x; - if dx == 0 { - return false; - } - self.translate_selection_with_undo_with(measurer, dx, 0) + // Align with the current view, which panning and zoom move away from + // the canvas origin. + let visible = self.visible_canvas_rect(); + let start = i64::from(visible.x); + let end = start + i64::from(visible.width); + self.align_selection_with_undo_with(measurer, |extent| { + let dx = if to_start { + start - extent.min_x + } else { + end - extent.max_x + }; + (dx, 0) + }) } pub(crate) fn move_selection_to_vertical_edge_with( @@ -99,23 +105,61 @@ impl InputState { measurer: &TextMeasurer, to_start: bool, ) -> bool { - let Some(bounds) = self.movable_selection_bounds(measurer) else { - return false; - }; - let screen_height = self.view.screen_height().min(i32::MAX as u32) as i32; - if screen_height <= 0 { + if self.view.screen_height() == 0 { return false; } - let target_y = if to_start { - 0 - } else { - screen_height - bounds.height - }; - let dy = target_y - bounds.y; - if dy == 0 { + let visible = self.visible_canvas_rect(); + let start = i64::from(visible.y); + let end = start + i64::from(visible.height); + self.align_selection_with_undo_with(measurer, |extent| { + let dy = if to_start { + start - extent.min_y + } else { + end - extent.max_y + }; + (0, dy) + }) + } + + /// Moves the selection by the delta that `delta_for` returns for its + /// current painted bounds, as one undo step. + /// + /// A delta can exceed the `i32` range, so it is applied in steps, and + /// the bounds are measured again after each one. Each step is limited + /// like any other move, and alignment stops when nothing is left to move + /// or a step is fully blocked. + fn align_selection_with_undo_with( + &mut self, + measurer: &TextMeasurer, + delta_for: impl Fn(SelectionExtent) -> (i64, i64), + ) -> bool { + let before = self.capture_movable_selection_snapshots(); + if before.is_empty() { return false; } - self.translate_selection_with_undo_with(measurer, 0, dy) + + let mut moved = false; + for _ in 0..MAX_ALIGNMENT_STEPS { + let Some(extent) = self.movable_selection_extent(measurer) else { + break; + }; + let (dx, dy) = delta_for(extent); + if (dx, dy) == (0, 0) + || !self.apply_translation_to_selection_with( + measurer, + bounds::saturating_delta(dx), + bounds::saturating_delta(dy), + ) + { + break; + } + moved = true; + } + + if moved { + self.push_translation_undo(measurer, before); + } + moved } } diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index e2c6f64d..a449227d 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -107,12 +107,17 @@ pub(crate) fn handle_active_motion( // The selection holds still until the pointer leaves the click // radius, so neither a click nor either half of a double-click nudges // it; past the radius it catches up and follows the pointer exactly. - let dragging = grab.track(canvas.x(), canvas.y()); + // The anchor stays at the press until then, which is what lets the + // first drag motion catch up. + if !grab.track(canvas.x(), canvas.y()) { + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::MovingSelection, + )); + } let dx = canvas.x() - *last_x; let dy = canvas.y() - *last_y; - let translated = dragging - && (dx != 0 || dy != 0) - && state.apply_translation_to_selection_with(measurer, dx, dy); + let translated = + (dx != 0 || dy != 0) && state.apply_translation_to_selection_with(measurer, dx, dy); if let DrawingState::MovingSelection { last_x, last_y, @@ -120,6 +125,8 @@ pub(crate) fn handle_active_motion( .. } = &mut state.state { + // The anchor follows the pointer even when an edge blocked the + // move, so reversing direction moves the selection at once. *last_x = canvas.x(); *last_y = canvas.y(); if translated { diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 56f4bca2..fe0f1374 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -198,6 +198,11 @@ pub(crate) mod test_support { self } + pub(crate) fn boards_config(mut self, boards_config: BoardsConfig) -> Self { + self.seed.boards_config = boards_config; + self + } + pub(crate) fn build(self) -> InputState { InputState::from_seed(self.seed) } diff --git a/src/input/state/tests/selection/damage.rs b/src/input/state/tests/selection/damage.rs index 8db8a4ec..dcfc2c52 100644 --- a/src/input/state/tests/selection/damage.rs +++ b/src/input/state/tests/selection/damage.rs @@ -187,3 +187,37 @@ fn deselecting_text_repaints_its_resize_handle() { assert!(!state.has_selection()); assert_damage_covers(&damage, handle, "text resize handle"); } + +#[test] +fn a_selection_without_chrome_bounds_still_repaints_its_visible_members() { + let mut state = damage_state(); + let visible = add_filled_rect(&mut state, 20, 20); + let at_limit = state.boards.active_frame_mut().add_shape(Shape::Line { + x1: i32::MAX - 50, + y1: 100, + x2: i32::MAX, + y2: 150, + color: state.style.current_color, + thick: 4.0, + }); + let _ = state.take_dirty_regions(); + + state.set_selection(vec![visible, at_limit]); + assert_eq!( + state.selection_bounds(), + None, + "the fixture's painted bounds must end past i32::MAX" + ); + let damage = state.take_dirty_regions(); + assert_damage_covers(&damage, chrome_of(&state, visible), "new visible member"); + + state.on_key_press(Key::Escape); + let damage = state.take_dirty_regions(); + + assert!(!state.has_selection()); + assert_damage_covers( + &damage, + chrome_of(&state, visible), + "previous visible member", + ); +} diff --git a/src/input/state/tests/selection/mod.rs b/src/input/state/tests/selection/mod.rs index e6d24697..eee5c339 100644 --- a/src/input/state/tests/selection/mod.rs +++ b/src/input/state/tests/selection/mod.rs @@ -7,4 +7,5 @@ mod deletion; mod double_click; mod duplicate; mod mid_drag_actions; +mod movement; mod reorder; diff --git a/src/input/state/tests/selection/movement.rs b/src/input/state/tests/selection/movement.rs new file mode 100644 index 00000000..39f46a88 --- /dev/null +++ b/src/input/state/tests/selection/movement.rs @@ -0,0 +1,1145 @@ +use super::*; +use crate::config::BoardsConfig; +use crate::draw::{ShapeId, TextMeasurer}; +use crate::input::state::InputTextResources; +use crate::input::state::test_support::TestInputStateBuilder; +use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; +use crate::ui_text::UiTextEngine; +use crate::util::Rect; + +fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> ShapeId { + let color = state.style.current_color; + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y, + w, + h, + fill: true, + fill_color: None, + color, + thick: 4.0, + }) +} + +fn add_line(state: &mut InputState, (x1, y1): (i32, i32), (x2, y2): (i32, i32)) -> ShapeId { + let color = state.style.current_color; + state.boards.active_frame_mut().add_shape(Shape::Line { + x1, + y1, + x2, + y2, + color, + thick: 4.0, + }) +} + +fn shape(state: &InputState, id: ShapeId) -> Shape { + state + .boards + .active_frame() + .shape(id) + .expect("the shape stays in the frame") + .shape + .clone() +} + +fn rect_origin(state: &InputState, id: ShapeId) -> (i32, i32) { + match shape(state, id) { + Shape::Rect { x, y, .. } => (x, y), + other => panic!("expected a rectangle, got {other:?}"), + } +} + +fn line_ends(state: &InputState, id: ShapeId) -> ((i32, i32), (i32, i32)) { + match shape(state, id) { + Shape::Line { x1, y1, x2, y2, .. } => ((x1, y1), (x2, y2)), + other => panic!("expected a line, got {other:?}"), + } +} + +fn painted(state: &InputState) -> Rect { + state + .selection_bounds() + .expect("the selection has painted bounds") +} + +fn painted_right(bounds: Rect) -> i64 { + i64::from(bounds.x) + i64::from(bounds.width) +} + +fn whiteboard() -> InputState { + let mut state = create_test_input_state(); + state.update_screen_dimensions(800, 600); + state.switch_board(BOARD_ID_WHITEBOARD); + state +} + +/// The two fixed 800x600 canvases: the transparent overlay and a solid board +/// with panning disabled. +fn fixed_canvases() -> Vec<(&'static str, InputState)> { + let mut overlay = create_test_input_state(); + overlay.update_screen_dimensions(800, 600); + overlay.switch_board(BOARD_ID_TRANSPARENT); + + let mut solid = TestInputStateBuilder::default() + .boards_config(BoardsConfig { + pan_enabled: false, + ..BoardsConfig::default() + }) + .build(); + solid.update_screen_dimensions(800, 600); + solid.switch_board(BOARD_ID_WHITEBOARD); + assert!( + !solid.boards.pan_enabled() && !solid.board_is_transparent(), + "the second fixture must be a solid board with panning disabled" + ); + + vec![ + ("transparent overlay", overlay), + ("unpannable board", solid), + ] +} + +#[test] +fn crossing_the_click_radius_catches_up_with_the_press() { + for board in [BOARD_ID_WHITEBOARD, BOARD_ID_TRANSPARENT] { + let mut state = create_test_input_state(); + state.update_screen_dimensions(800, 600); + state.switch_board(board); + state.set_tool_override(Some(Tool::Select)); + let id = add_rect(&mut state, 100, 100, 40, 40); + + state.on_mouse_press(MouseButton::Left, 120, 120); + state.on_mouse_motion(121, 120); + state.on_mouse_motion(122, 120); + state.on_mouse_motion(126, 120); + state.on_mouse_release(MouseButton::Left, 126, 120); + + assert_eq!( + rect_origin(&state, id), + (106, 100), + "the drag must move by the whole pointer travel since the press on {board}" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 1, + "one drag must record one undo entry on {board}" + ); + } +} + +#[test] +fn wobble_inside_the_click_radius_does_not_move_the_selection() { + let mut state = whiteboard(); + state.set_tool_override(Some(Tool::Select)); + let id = add_rect(&mut state, 100, 100, 40, 40); + + state.on_mouse_press(MouseButton::Left, 120, 120); + state.on_mouse_motion(121, 121); + state.on_mouse_motion(122, 119); + state.on_mouse_release(MouseButton::Left, 122, 119); + + assert_eq!( + rect_origin(&state, id), + (100, 100), + "a click must not nudge" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 0, + "a click must not record a move" + ); +} + +#[test] +fn a_blocked_drag_moves_again_as_soon_as_it_reverses() { + for (canvas, mut state) in fixed_canvases() { + state.set_tool_override(Some(Tool::Select)); + add_rect(&mut state, 700, 100, 40, 40); + + state.on_mouse_press(MouseButton::Left, 720, 120); + state.on_mouse_motion(790, 120); + let at_edge = painted(&state); + assert_eq!( + painted_right(at_edge), + 800, + "the drag must stop at the right edge of the {canvas}" + ); + + state.on_mouse_motion(795, 120); + assert_eq!( + painted(&state), + at_edge, + "further outward motion must be dropped on the {canvas}" + ); + + state.on_mouse_motion(785, 120); + assert_eq!( + painted(&state).x, + at_edge.x - 10, + "reversing must move the selection by the reverse travel at once on the {canvas}" + ); + + state.on_mouse_motion(785, 150); + let after = painted(&state); + assert_eq!( + (after.x, after.y), + (at_edge.x - 10, at_edge.y + 30), + "motion on the other axis must not jump on the {canvas}" + ); + + state.on_mouse_release(MouseButton::Left, 785, 150); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 1, + "the whole drag must record one undo entry on the {canvas}" + ); + } +} + +#[test] +fn dragging_on_a_pannable_board_follows_the_pointer_in_any_view() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (board_offset, zoom, origin) in [ + ((0, 0), None, (0, 0)), + ((1600, 1200), None, (1600, 1200)), + ((-1600, -1200), None, (-1600, -1200)), + ((0, 0), Some((2.0, (100.0, 50.0))), (100, 50)), + ((1600, 1200), Some((2.0, (100.0, 50.0))), (1700, 1250)), + ] { + let mut state = whiteboard(); + state + .boards + .active_frame_mut() + .set_view_offset(board_offset.0, board_offset.1); + if let Some((scale, offset)) = zoom { + state.set_zoom_status(true, false, scale, offset); + } + state.set_tool_override(Some(Tool::Select)); + let (x, y) = origin; + let original = Shape::Freehand { + points: vec![ + (f64::from(x + 100), f64::from(y + 100)), + (f64::from(x + 120), f64::from(y + 130)), + (f64::from(x + 140), f64::from(y + 160)), + ], + color: state.style.current_color, + thick: 4.0, + }; + let stroke = state.boards.active_frame_mut().add_shape(original.clone()); + + state.on_mouse_press(MouseButton::Left, x + 80, y + 80); + state.on_mouse_motion(x + 160, y + 180); + state.on_mouse_release(MouseButton::Left, x + 160, y + 180); + assert_eq!(state.selected_shape_ids(), &[stroke], "view {origin:?}"); + + state.on_mouse_press(MouseButton::Left, x + 120, y + 130); + state.on_mouse_motion(x + 160, y + 160); + state.on_mouse_motion(x + 170, y + 170); + state.on_mouse_release(MouseButton::Left, x + 170, y + 170); + + let expected = Shape::Freehand { + points: vec![ + (f64::from(x + 150), f64::from(y + 140)), + (f64::from(x + 170), f64::from(y + 170)), + (f64::from(x + 190), f64::from(y + 200)), + ], + color: state.style.current_color, + thick: 4.0, + }; + assert_eq!( + shape(&state, stroke), + expected, + "the stroke must follow the pointer in view {origin:?}" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 1, + "one drag must create one undo entry in view {origin:?}" + ); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!( + shape(&state, stroke), + original, + "undo must restore the original stroke in view {origin:?}" + ); + state.handle_action_with_resources(resources, Action::Redo); + assert_eq!( + shape(&state, stroke), + expected, + "redo must restore the moved stroke in view {origin:?}" + ); + } +} + +#[test] +fn a_fixed_canvas_never_pulls_back_a_selection_that_crosses_an_edge() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (canvas, mut state) in fixed_canvases() { + let id = add_rect(&mut state, 780, 100, 40, 40); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + assert_eq!( + rect_origin(&state, id), + (780, 100), + "an outward nudge must neither move nor snap a clipped selection on the {canvas}" + ); + + state.handle_action_with_resources(resources, Action::NudgeSelectionLeft); + assert_eq!( + rect_origin(&state, id), + (772, 100), + "an inward nudge must move by exactly one step on the {canvas}" + ); + } +} + +#[test] +fn a_fixed_canvas_blocks_a_selection_outside_it_only_outward() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (canvas, mut state) in fixed_canvases() { + for (origin, outward, inward, moved) in [ + ( + (3000, 100), + Action::NudgeSelectionRight, + Action::NudgeSelectionLeft, + (2992, 100), + ), + ( + (-3000, 100), + Action::NudgeSelectionLeft, + Action::NudgeSelectionRight, + (-2992, 100), + ), + ( + (100, 3000), + Action::NudgeSelectionDown, + Action::NudgeSelectionUp, + (100, 2992), + ), + ( + (100, -3000), + Action::NudgeSelectionUp, + Action::NudgeSelectionDown, + (100, -2992), + ), + ] { + let id = add_rect(&mut state, origin.0, origin.1, 40, 40); + state.set_selection(vec![id]); + let undo_depth = state.boards.active_frame().undo_stack_len(); + + state.handle_action_with_resources(resources, outward); + assert_eq!( + rect_origin(&state, id), + origin, + "{outward:?} must not move a selection outside the {canvas}" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + undo_depth, + "a blocked {outward:?} must not record history on the {canvas}" + ); + + state.handle_action_with_resources(resources, inward); + assert_eq!( + rect_origin(&state, id), + moved, + "{inward:?} must move one step without jumping toward the {canvas}" + ); + } + } +} + +#[test] +fn an_oversized_selection_slides_only_while_it_covers_a_fixed_canvas() { + let measurer = TextMeasurer::default(); + + for (canvas, mut state) in fixed_canvases() { + let id = add_rect(&mut state, -100, 100, 1000, 40); + state.set_selection(vec![id]); + + assert!(state.translate_selection_with_undo_with(&measurer, 500, 0)); + assert_eq!( + painted(&state).x, + 0, + "the left edge must stop at the left edge of the {canvas}" + ); + + assert!(state.translate_selection_with_undo_with(&measurer, -500, 0)); + assert_eq!( + painted_right(painted(&state)), + 800, + "the right edge must stop at the right edge of the {canvas}" + ); + } +} + +#[test] +fn zoom_does_not_shrink_a_fixed_canvas() { + let measurer = TextMeasurer::default(); + + for (canvas, mut state) in fixed_canvases() { + state.set_zoom_status(true, false, 2.0, (0.0, 0.0)); + let id = add_rect(&mut state, 300, 100, 40, 40); + state.set_selection(vec![id]); + + assert!(state.translate_selection_with_undo_with(&measurer, 1000, 0)); + assert_eq!( + painted_right(painted(&state)), + 800, + "at 2x zoom the {canvas} must still end at the screen width, not the visible area" + ); + } +} + +#[test] +fn an_unknown_screen_dimension_leaves_that_axis_of_a_fixed_canvas_free() { + let measurer = TextMeasurer::default(); + + for (width, height, delta, expected) in [ + (0, 600, (5000, 0), (5100, 100)), + (800, 0, (0, 5000), (100, 5100)), + ] { + for (canvas, mut state) in fixed_canvases() { + state.update_screen_dimensions(width, height); + let id = add_rect(&mut state, 100, 100, 40, 40); + state.set_selection(vec![id]); + + assert!(state.translate_selection_with_undo_with(&measurer, delta.0, delta.1)); + assert_eq!( + rect_origin(&state, id), + expected, + "a {width}x{height} {canvas} must not limit its unknown axis" + ); + } + } +} + +#[test] +fn edge_actions_align_with_the_visible_canvas() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (board, offset, zoom, viewport) in [ + ( + BOARD_ID_WHITEBOARD, + (1600, 1200), + false, + (1600, 1200, 800, 600), + ), + ( + BOARD_ID_WHITEBOARD, + (-1600, -1200), + false, + (-1600, -1200, 800, 600), + ), + ( + BOARD_ID_WHITEBOARD, + (1600, 1200), + true, + (1700, 1250, 400, 300), + ), + (BOARD_ID_TRANSPARENT, (0, 0), true, (100, 50, 400, 300)), + ] { + let mut state = create_test_input_state(); + state.update_screen_dimensions(800, 600); + state.switch_board(board); + state + .boards + .active_frame_mut() + .set_view_offset(offset.0, offset.1); + if zoom { + state.set_zoom_status(true, false, 2.0, (100.0, 50.0)); + } + let (left, top, width, height) = viewport; + let id = add_rect(&mut state, left + 100, top + 100, 40, 40); + state.set_selection(vec![id]); + + for (action, expected_x, expected_y) in [ + (Action::MoveSelectionToStart, left, top + 98), + (Action::MoveSelectionToTop, left, top), + (Action::MoveSelectionToEnd, left + width - 44, top), + ( + Action::MoveSelectionToBottom, + left + width - 44, + top + height - 44, + ), + ] { + state.handle_action_with_resources(resources, action); + + let bounds = painted(&state); + assert_eq!( + (bounds.x, bounds.y, bounds.width, bounds.height), + (expected_x, expected_y, 44, 44), + "{action:?} must align with viewport {viewport:?} on {board}" + ); + } + } +} + +#[test] +fn edge_actions_do_not_wall_off_a_pannable_board() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + state.boards.active_frame_mut().set_view_offset(1600, 1200); + let id = add_rect(&mut state, 1700, 1300, 40, 40); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, Action::MoveSelectionToStart); + state.handle_action_with_resources(resources, Action::NudgeSelectionLeft); + + assert_eq!( + painted(&state).x, + 1592, + "a nudge after an edge action must keep moving past the visible edge" + ); +} + +#[test] +fn edge_actions_are_unavailable_without_the_screen_dimension() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (width, height, action) in [ + (0, 600, Action::MoveSelectionToEnd), + (800, 0, Action::MoveSelectionToBottom), + ] { + let mut state = whiteboard(); + state.update_screen_dimensions(width, height); + let id = add_rect(&mut state, 100, 100, 40, 40); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, action); + + assert_eq!( + rect_origin(&state, id), + (100, 100), + "{action:?} needs the {width}x{height} screen dimension it aligns with" + ); + } +} + +#[test] +fn nudges_on_a_pannable_board_stop_at_the_coordinate_limits() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (view_x, ends, action, expected) in [ + ( + i32::MAX - 800, + ((i32::MAX - 50, 100), (i32::MAX - 4, 150)), + Action::NudgeSelectionRight, + ((i32::MAX - 48, 100), (i32::MAX - 2, 150)), + ), + ( + i32::MIN, + ((i32::MIN + 4, 100), (i32::MIN + 50, 150)), + Action::NudgeSelectionLeft, + ((i32::MIN + 2, 100), (i32::MIN + 48, 150)), + ), + ] { + let mut state = whiteboard(); + state.boards.active_frame_mut().set_view_offset(view_x, 0); + let id = add_line(&mut state, ends.0, ends.1); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, action); + + // Release builds wrap on overflow, so check the stored coordinates + // rather than only the absence of a panic. + assert_eq!( + line_ends(&state, id), + expected, + "{action:?} must shorten the move so the line and its stroke padding stay representable" + ); + } +} + +#[test] +fn nudges_at_an_extreme_view_offset_move_normally() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + state + .boards + .active_frame_mut() + .set_view_offset(i32::MIN, i32::MIN); + let id = add_rect(&mut state, 100, 100, 1000, 1000); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + state.handle_action_with_resources(resources, Action::NudgeSelectionDown); + + assert_eq!( + rect_origin(&state, id), + (108, 108), + "the camera position must not limit or redirect a valid nudge" + ); +} + +#[test] +fn edge_actions_keep_oversized_lines_within_coordinate_limits() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for offset in [i32::MAX, i32::MAX - 800] { + let mut state = whiteboard(); + state + .boards + .active_frame_mut() + .set_view_offset(offset, offset); + let line = add_line(&mut state, (0, 0), (1000, 1000)); + state.set_selection(vec![line]); + + state.handle_action_with_resources(resources, Action::MoveSelectionToStart); + state.handle_action_with_resources(resources, Action::MoveSelectionToTop); + + assert_eq!( + line_ends(&state, line), + ( + (i32::MAX - 1002, i32::MAX - 1002), + (i32::MAX - 2, i32::MAX - 2) + ), + "edge moves must keep the line's length and leave room for its stroke padding" + ); + } +} + +#[test] +fn edge_actions_keep_text_and_note_anchors_within_coordinate_limits() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (offset, text, action, anchor, expected, is_note) in [ + ( + i32::MAX, + "'", + Action::MoveSelectionToTop, + (1000, 1000), + (1000, i32::MAX), + false, + ), + ( + i32::MIN, + " x", + Action::MoveSelectionToStart, + (-1000, 1000), + (i32::MIN, 1000), + false, + ), + ( + i32::MIN, + "אבג", + Action::MoveSelectionToStart, + (-1000, 1000), + (i32::MIN, 1000), + true, + ), + ] { + let mut state = whiteboard(); + state + .boards + .active_frame_mut() + .set_view_offset(offset, offset); + let shape = if is_note { + Shape::StickyNote { + x: anchor.0, + y: anchor.1, + text: text.to_string(), + background: state.style.current_color, + size: 32.0, + font_descriptor: state.style.font_descriptor.clone(), + wrap_width: Some(300), + } + } else { + Shape::Text { + x: anchor.0, + y: anchor.1, + text: text.to_string(), + color: state.style.current_color, + size: 32.0, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + } + }; + let id = state.boards.active_frame_mut().add_shape(shape); + state.set_selection(vec![id]); + let bounds = painted(&state); + match action { + Action::MoveSelectionToStart => assert!( + bounds.x > anchor.0, + "the horizontal fixture must have its anchor left of the painted bounds" + ), + Action::MoveSelectionToTop => assert!( + bounds.y + bounds.height < anchor.1, + "the vertical fixture must have its baseline below the painted bounds" + ), + other => panic!("unexpected edge action {other:?}"), + } + + state.handle_action_with_resources(resources, action); + + match state + .boards + .active_frame() + .shape(id) + .expect("text survives edge move") + .shape + { + Shape::Text { x, y, .. } | Shape::StickyNote { x, y, .. } => assert_eq!( + (x, y), + expected, + "the text anchor must stop at the coordinate boundary" + ), + ref other => panic!("expected the original text shape, got {other:?}"), + } + } +} + +#[test] +fn painted_bounds_past_i32_max_still_allow_inward_moves() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + state.set_tool_override(Some(Tool::Select)); + // Screen (0, 0) is canvas (i32::MAX - 800, 0). + state + .boards + .active_frame_mut() + .set_view_offset(i32::MAX - 800, 0); + let id = add_line(&mut state, (i32::MAX - 50, 100), (i32::MAX, 150)); + state.set_selection(vec![id]); + let bounds = state + .boards + .active_frame() + .shape(id) + .and_then(|drawn| drawn.bounding_box()) + .expect("the line has painted bounds"); + assert!( + painted_right(bounds) > i64::from(i32::MAX), + "the fixture's painted bounds must end past i32::MAX, got {bounds:?}" + ); + assert_eq!( + state.selection_bounds(), + None, + "selection code computes x + width in i32, so these bounds have no Rect" + ); + + // Hovering and clicking the line reach handle hit-testing, and the + // double-click opens Properties; none of them may overflow. + let (screen_x, screen_y) = (790, 140); + let (canvas_x, canvas_y) = (i32::MAX - 10, 140); + state.on_mouse_motion_with_canvas(screen_x, screen_y, canvas_x, canvas_y); + for _ in 0..2 { + state.on_mouse_press_with_canvas(MouseButton::Left, screen_x, screen_y, canvas_x, canvas_y); + state.on_mouse_release_with_canvas( + MouseButton::Left, + screen_x, + screen_y, + canvas_x, + canvas_y, + ); + } + assert!( + state.is_properties_panel_open(), + "a double-click on the line must open Properties" + ); + state.close_properties_panel(); + assert_eq!( + state.selected_shape_ids(), + &[id], + "clicking the line must keep it selected" + ); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + assert_eq!( + line_ends(&state, id), + ((i32::MAX - 50, 100), (i32::MAX, 150)), + "an outward nudge must not move a line already at the limit" + ); + + state.handle_action_with_resources(resources, Action::NudgeSelectionLeft); + assert_eq!( + line_ends(&state, id), + ((i32::MAX - 58, 100), (i32::MAX - 8, 150)), + "an inward nudge must move by exactly one step" + ); + assert!( + state.selection_bounds().is_some(), + "the selection bounds must return once they fit in i32" + ); +} + +#[test] +fn a_selection_without_painted_bounds_keeps_its_coordinate_limits() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let empty_text = |state: &InputState, x: i32| Shape::Text { + x, + y: 100, + text: String::new(), + color: state.style.current_color, + size: 32.0, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + }; + + let mut state = whiteboard(); + let text = empty_text(&state, i32::MAX - 4); + let id = state.boards.active_frame_mut().add_shape(text); + state.set_selection(vec![id]); + assert_eq!( + state.selection_bounds(), + None, + "the fixture must have no painted bounds" + ); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + match shape(&state, id) { + Shape::Text { x, y, .. } => assert_eq!( + (x, y), + (i32::MAX, 100), + "without painted bounds the anchor alone must limit a pannable-board nudge" + ), + other => panic!("expected text, got {other:?}"), + } + + for (canvas, mut state) in fixed_canvases() { + let text = empty_text(&state, 100); + let id = state.boards.active_frame_mut().add_shape(text); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + + match shape(&state, id) { + Shape::Text { x, y, .. } => assert_eq!( + (x, y), + (100, 100), + "the {canvas} cannot place a selection without painted bounds, so it must not move it" + ), + other => panic!("expected text, got {other:?}"), + } + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 0, + "a blocked nudge must not record history on the {canvas}" + ); + } +} + +#[test] +fn a_selection_wider_than_a_rect_still_moves_on_a_pannable_board() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + let near_min = add_rect(&mut state, i32::MIN + 1000, 100, 40, 40); + let near_max = add_rect(&mut state, i32::MAX - 1000, 100, 40, 40); + state.set_selection(vec![near_min, near_max]); + assert_eq!( + state.selection_bounds(), + None, + "the fixture's union must be too wide for one Rect" + ); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + + assert_eq!( + (rect_origin(&state, near_min), rect_origin(&state, near_max)), + ((i32::MIN + 1008, 100), (i32::MAX - 992, 100)), + "both shapes must move by the requested step" + ); +} + +#[test] +fn a_group_moves_by_one_delta_that_locked_shapes_do_not_limit() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + let free = add_rect(&mut state, 100, 100, 40, 40); + let near_limit = add_line(&mut state, (i32::MAX - 50, 100), (i32::MAX - 6, 150)); + let locked = add_line(&mut state, (i32::MAX - 50, 200), (i32::MAX - 1, 250)); + state + .boards + .active_frame_mut() + .shape_mut(locked) + .expect("locked line") + .locked = true; + state.set_selection(vec![free, near_limit, locked]); + + state.handle_action_with_resources(resources, Action::NudgeSelectionRight); + + assert_eq!( + rect_origin(&state, free), + (104, 100), + "the group must share the delta that the line near the limit allows" + ); + assert_eq!( + line_ends(&state, near_limit), + ((i32::MAX - 46, 100), (i32::MAX - 2, 150)), + "the line near the limit must keep room for its stroke padding" + ); + assert_eq!( + line_ends(&state, locked), + ((i32::MAX - 50, 200), (i32::MAX - 1, 250)), + "a locked shape must neither move nor limit the group" + ); +} + +#[test] +fn edge_actions_reach_a_view_more_than_i32_max_away_in_one_undo_step() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (offset, origin, action, expected_edge) in [ + ( + (1600, 1200), + (i32::MIN + 1000, 1300), + Action::MoveSelectionToStart, + 1600, + ), + ( + (-1600, -1200), + (i32::MAX - 1000, -1100), + Action::MoveSelectionToEnd, + -800, + ), + ] { + let mut state = whiteboard(); + state + .boards + .active_frame_mut() + .set_view_offset(offset.0, offset.1); + let id = add_rect(&mut state, origin.0, origin.1, 40, 40); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, action); + + let bounds = painted(&state); + let edge = if action == Action::MoveSelectionToStart { + i64::from(bounds.x) + } else { + painted_right(bounds) + }; + assert_eq!( + edge, + i64::from(expected_edge), + "{action:?} must reach the visible edge from {origin:?}" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 1, + "{action:?} must record one undo entry" + ); + + state.handle_action_with_resources(resources, Action::Undo); + assert_eq!( + rect_origin(&state, id), + origin, + "undo must restore the rectangle in one step" + ); + } +} + +#[test] +fn text_moved_to_the_coordinate_limit_survives_deselect_and_undo() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = whiteboard(); + // The visible canvas ends exactly at i32::MAX. + state + .boards + .active_frame_mut() + .set_view_offset(i32::MAX - 800, 0); + let text = Shape::Text { + x: i32::MAX - 1000, + y: 100, + text: "Handle".to_string(), + color: state.style.current_color, + size: 32.0, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: false, + wrap_width: None, + }; + let id = state.boards.active_frame_mut().add_shape(text); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, Action::MoveSelectionToEnd); + assert_eq!( + painted_right(painted(&state)), + i64::from(i32::MAX), + "the text must reach the visible edge at the coordinate limit" + ); + assert_eq!( + state.selected_text_resize_handle_with(&measurer), + None, + "a resize handle past i32::MAX cannot be represented" + ); + + state.on_key_press(Key::Escape); + assert!(!state.has_selection(), "Escape must clear the selection"); + state.handle_action_with_resources(resources, Action::Undo); + + match shape(&state, id) { + Shape::Text { x, y, .. } => { + assert_eq!((x, y), (i32::MAX - 1000, 100), "undo must restore the text") + } + other => panic!("expected text, got {other:?}"), + } +} + +#[test] +fn edge_actions_align_lines_whose_bounds_were_clipped_at_the_limit() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + + for (view_x, ends, thick, action, expected_edge) in [ + ( + 0, + ((i32::MAX - 50, 100), (i32::MAX, 150)), + 4.0, + Action::MoveSelectionToEnd, + 800, + ), + ( + 0, + ((i32::MIN, 100), (i32::MIN + 50, 150)), + 4.0, + Action::MoveSelectionToStart, + 0, + ), + // The stroke padding hidden past the limit is wider than one pixel. + ( + i32::MAX - 800, + ((i32::MAX - 50, 100), (i32::MAX, 150)), + 20.0, + Action::MoveSelectionToEnd, + i64::from(i32::MAX), + ), + ] { + let mut state = whiteboard(); + state.boards.active_frame_mut().set_view_offset(view_x, 0); + let color = state.style.current_color; + let id = state.boards.active_frame_mut().add_shape(Shape::Line { + x1: ends.0.0, + y1: ends.0.1, + x2: ends.1.0, + y2: ends.1.1, + color, + thick, + }); + state.set_selection(vec![id]); + + state.handle_action_with_resources(resources, action); + + let bounds = painted(&state); + let edge = if action == Action::MoveSelectionToStart { + i64::from(bounds.x) + } else { + painted_right(bounds) + }; + assert_eq!( + edge, expected_edge, + "{action:?} must align the full {thick}px stroke padding with the view, got {bounds:?}" + ); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 1, + "{action:?} must record one undo entry" + ); + } +} + +#[test] +fn handle_hit_testing_far_from_a_selection_near_i32_min_does_not_overflow() { + let mut state = whiteboard(); + state.set_tool_override(Some(Tool::Select)); + let id = add_rect(&mut state, i32::MIN + 10, 100, 40, 40); + state.set_selection(vec![id]); + + state.on_mouse_motion(100, 100); + + assert_eq!( + state.hit_selection_handle(100, 100), + None, + "a pointer i32::MAX away from every handle must not hit one" + ); +} diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index c7695686..a84b1421 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -374,9 +374,11 @@ fn nudge_selection_clamps_right_and_bottom_edges() { let mut state = create_test_input_state(); state.update_screen_dimensions(100, 100); + // Start with the stroke padding inside the canvas: a selection that + // already crosses an edge is not pulled back. let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { - x: 90, - y: 90, + x: 87, + y: 87, w: 10, h: 10, fill: false, @@ -667,10 +669,17 @@ fn moving_a_selection_on_transparent_board_clamps_to_screen() { thick: state.style.current_thickness, }); state.set_selection(vec![shape_id]); + let before = state + .boards + .active_frame() + .shape(shape_id) + .unwrap() + .bounding_box() + .expect("rect should have bounds"); // Transparent overlay is bounded by screen (1920x1080). // A 100px-wide shape at x=1800 can only slide to x=1820 (with bounds padding) before hitting screen edge. - state.apply_translation_to_selection_with(&measurer, 300, 0); + assert!(state.apply_translation_to_selection_with(&measurer, 300, 0)); let after = state .boards .active_frame() @@ -679,5 +688,9 @@ fn moving_a_selection_on_transparent_board_clamps_to_screen() { .bounding_box() .expect("rect should have bounds"); - assert!(after.x + after.width <= 1920); + assert_eq!( + (after.x + after.width, after.width), + (1920, before.width), + "the selection must stop exactly at the screen edge, before {before:?}" + ); }