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 0c027215..31000339 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -1,132 +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() + } + + /// 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() + } - delta.clamp(min_delta, max_delta) + 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)> { - 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 { - 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, start_x, end_x, 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, start_y, end_y, 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 2db2597c..a449227d 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -107,22 +107,31 @@ 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; - if 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 + let translated = + (dx != 0 || dy != 0) && state.apply_translation_to_selection_with(measurer, dx, dy); + if let DrawingState::MovingSelection { + last_x, + last_y, + moved, + .. + } = &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(); - *moved = true; + if translated { + *moved = true; + } } return Some(RoutingOutcome::Continued( ActiveInteractionKind::MovingSelection, 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 6abf7a1f..a84b1421 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(); @@ -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, @@ -565,10 +567,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 +579,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 +610,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,7 +635,51 @@ fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { .unwrap() .bounding_box() .expect("rect should have bounds"); - state.apply_translation_to_selection_with(&measurer, 300, 0); + + // 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]); + 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. + assert!(state.apply_translation_to_selection_with(&measurer, 300, 0)); let after = state .boards .active_frame() @@ -644,8 +689,8 @@ fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { .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" + (after.x + after.width, after.width), + (1920, before.width), + "the selection must stop exactly at the screen edge, before {before:?}" ); }