diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 58d4a7fa3..0c0272157 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -77,16 +77,23 @@ impl InputState { } } - fn clamp_axis_delta(position: i32, size: i32, screen: i32, delta: i32) -> i32 { - if screen <= 0 || size <= 0 { + fn clamp_axis_delta(position: i32, size: i32, start: i32, end_limit: i32, delta: i32) -> i32 { + if end_limit <= start || size <= 0 { return delta; } + let extent = end_limit.saturating_sub(start); let end = position.saturating_add(size); - let (min_delta, max_delta) = if size <= screen { - (0i32.saturating_sub(position), screen.saturating_sub(end)) + let (min_delta, max_delta) = if size <= extent { + ( + start.saturating_sub(position), + end_limit.saturating_sub(end), + ) } else { - (screen.saturating_sub(end), 0i32.saturating_sub(position)) + ( + end_limit.saturating_sub(end), + start.saturating_sub(position), + ) }; delta.clamp(min_delta, max_delta) @@ -100,18 +107,24 @@ impl InputState { ) -> Option<(i32, i32)> { 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; + // 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 { - 0 + let clamped_dx = if dx == 0 || screen_width == 0 { + dx } else { - Self::clamp_axis_delta(bounds.x, bounds.width, screen_width, dx) + Self::clamp_axis_delta(bounds.x, bounds.width, start_x, end_x, dx) }; - let clamped_dy = if dy == 0 { - 0 + let clamped_dy = if dy == 0 || screen_height == 0 { + dy } else { - Self::clamp_axis_delta(bounds.y, bounds.height, screen_height, dy) + Self::clamp_axis_delta(bounds.y, bounds.height, start_y, end_y, dy) }; Some((clamped_dx, clamped_dy)) diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 0275cedc1..0727d64ad 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -5,7 +5,7 @@ use crate::util::Rect; use std::time::Instant; impl InputState { - fn board_view_offset(&self) -> (f64, f64) { + pub(crate) fn board_view_offset(&self) -> (f64, f64) { if self.board_is_transparent() || !self.boards.pan_enabled() { (0.0, 0.0) } else { diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 22f361c23..b9401c445 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -555,3 +555,90 @@ fn stretching_a_flat_curved_arrow_downward_grows_its_arc() { other => panic!("expected arrow, got {other:?}"), } } + +#[test] +fn moving_a_selection_on_a_panned_board_follows_the_pointer() { + let measurer = crate::draw::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)); + // 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 { + x: -3200, + y: 193, + w: 480, + h: 100, + fill: false, + 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"); + + assert!(state.apply_translation_to_selection_with(&measurer, 5, 0)); + + let after = state + .boards + .active_frame() + .shape(shape_id) + .unwrap() + .bounding_box() + .expect("rect should have bounds"); + assert_eq!( + after.x - before.x, + 5, + "a 5px drag must move the selection by 5 canvas px, not teleport it to the canvas origin" + ); +} + +#[test] +fn moving_a_selection_at_zoom_keeps_it_inside_the_visible_canvas() { + let measurer = crate::draw::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. + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 400, + h: 100, + fill: false, + 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"); + state.apply_translation_to_selection_with(&measurer, 300, 0); + let after = state + .boards + .active_frame() + .shape(shape_id) + .unwrap() + .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" + ); +}