diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 0f4b463d..f3765e57 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -1,7 +1,9 @@ use super::*; #[path = "geometry_aspect.rs"] mod aspect; -pub(crate) use aspect::{drag_frame, enforce_min_size, plot_min_size, preserve_aspect_frame}; +pub(crate) use aspect::{ + drag_frame, enforce_min_size, plot_min_size, preserve_aspect_frame, resize_min_size, +}; #[path = "geometry_panels.rs"] mod panels; pub(crate) use panels::{ diff --git a/crates/app/src/ui/canvas/geometry_aspect.rs b/crates/app/src/ui/canvas/geometry_aspect.rs index 0cfcd69f..5da2c9ac 100644 --- a/crates/app/src/ui/canvas/geometry_aspect.rs +++ b/crates/app/src/ui/canvas/geometry_aspect.rs @@ -1,10 +1,22 @@ use super::*; use plotium_core::state::MIN_CONTENT_FRAME_PT; -/// The frame a drag asks for. A resize holds the edges opposite the handle. An -/// edge already below the object minimum is not forced up to it. An -/// aspect-locked drag leaves the minimum to `aspect_locked_size`, which needs -/// the request free of it to scale from either edge. +/// The smallest size a resize of `frame` may ask for on each axis. An edge +/// already below the object minimum is not forced up to it. An aspect-locked +/// resize leaves the minimum to `aspect_locked_size`, which needs the request +/// free of it to scale from either edge. +pub(crate) fn resize_min_size(frame: ObjectFrame, keep_aspect: bool) -> [f32; 2] { + if keep_aspect { + [MIN_CONTENT_FRAME_PT; 2] + } else { + [ + MIN_OBJECT_SIZE_PT.min(frame.width), + MIN_OBJECT_SIZE_PT.min(frame.height), + ] + } +} + +/// The frame a drag asks for. A resize holds the edges opposite the handle. pub(crate) fn drag_frame( frame: ObjectFrame, kind: ObjectDragKind, @@ -15,14 +27,7 @@ pub(crate) fn drag_frame( let ObjectDragKind::Resize(handle) = kind else { return ObjectFrame::new(frame.x + dx, frame.y + dy, frame.width, frame.height); }; - let min = if keep_aspect { - [MIN_CONTENT_FRAME_PT; 2] - } else { - [ - MIN_OBJECT_SIZE_PT.min(frame.width), - MIN_OBJECT_SIZE_PT.min(frame.height), - ] - }; + let min = resize_min_size(frame, keep_aspect); let moves_left = matches!(handle, ResizeHandle::TopLeft | ResizeHandle::BottomLeft); let moves_top = matches!(handle, ResizeHandle::TopLeft | ResizeHandle::TopRight); let width = (frame.width + if moves_left { -dx } else { dx }).max(min[0]); diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 8535838b..eeaf373f 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -514,6 +514,7 @@ pub(crate) fn handle_object_interactions( before, start_pointer: start, start_pointer_screen: [screen_pos.x, screen_pos.y], + snap_axes: [false; 2], others, active: matches!(hit.kind, ObjectDragKind::Resize(_)), space, @@ -558,7 +559,7 @@ pub(crate) fn handle_object_interactions( Interaction::Object(d) if d.canvas == ci => Some(d.clone()), _ => None, }; - if let Some(drag) = object_drag { + if let Some(mut drag) = object_drag { let mut active = drag.active; if primary_down && let Some(screen_now) = hover @@ -574,8 +575,10 @@ pub(crate) fn handle_object_interactions( let dsx = screen_now.x - drag.start_pointer_screen[0]; let dsy = screen_now.y - drag.start_pointer_screen[1]; active |= dsx.hypot(dsy) > DRAG_START_PX; + latch_snap_axes(&mut drag.snap_axes, [dsx, dsy]); if let Interaction::Object(d) = &mut app.session.ui.interaction { d.active = active; + d.snap_axes = drag.snap_axes; } if active { if let Some(source) = tile_source_for_object(app, &drag) @@ -596,10 +599,12 @@ pub(crate) fn handle_object_interactions( if let ObjectDragSpace::Panel(panel) = drag.space { snap_panel_content_frame(app, ci, panel, &drag, candidate, ui) } else { - snap_object_frame(app, ci, &drag, candidate, ui) + snap_object_frame(app, ci, &drag, candidate, preserve_aspect, ui) }; if preserve_aspect { - snapped = preserve_aspect_frame(drag.before, snapped, drag.kind); + let requested = snapped; + snapped = preserve_aspect_frame(drag.before, requested, drag.kind); + retain_landed_guides(&mut guides, requested, snapped); } if let Some(plot) = app.doc.canvases[ci] .object(drag.object) diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index f84666de..c0ad7c9b 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -30,8 +30,10 @@ pub(crate) fn handle_panel_drag( let dsy = screen_now.y - drag.start_pointer_screen[1]; active |= dsx.hypot(dsy) > DRAG_START_PX; drag.active = active; + latch_snap_axes(&mut drag.snap_axes, [dsx, dsy]); if let Interaction::Panel(current) = &mut app.session.ui.interaction { current.active = active; + current.snap_axes = drag.snap_axes; } if active { let candidate = drag_frame(drag.before, drag.kind, dpx, dpy, false); @@ -187,6 +189,7 @@ pub(crate) fn begin_panel_drag( children, start_pointer: start, start_pointer_screen: [screen_pos.x, screen_pos.y], + snap_axes: [false; 2], active: matches!(kind, ObjectDragKind::Resize(_)), detached_since: None, })); diff --git a/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs index 6bb86c69..568735a8 100644 --- a/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs +++ b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs @@ -22,6 +22,7 @@ fn fixture() -> (PlotiumApp, egui::Context, EguiRect, Pos2) { children: vec![], start_pointer: [20.0, 20.0], start_pointer_screen: [origin.x + 20.0, origin.y + 20.0], + snap_axes: [false; 2], active: false, detached_since: None, }); diff --git a/crates/app/src/ui/canvas/panel_interactions_tests.rs b/crates/app/src/ui/canvas/panel_interactions_tests.rs index acd89b9b..26090fe1 100644 --- a/crates/app/src/ui/canvas/panel_interactions_tests.rs +++ b/crates/app/src/ui/canvas/panel_interactions_tests.rs @@ -36,6 +36,7 @@ fn clipped_content_cannot_be_committed_out_of_reach() { before, start_pointer: [30.0, 30.0], start_pointer_screen: [30.0, 30.0], + snap_axes: [false; 2], others: vec![], active: true, space: ObjectDragSpace::Panel(panel), @@ -190,6 +191,7 @@ fn content_move_group_move_and_resize_keep_off_panel_geometry_with_undo_redo() { before, start_pointer: [30.0, 30.0], start_pointer_screen: [30.0, 30.0], + snap_axes: [false; 2], others: if group { vec![(ids[1], before)] } else { diff --git a/crates/app/src/ui/canvas/snap.rs b/crates/app/src/ui/canvas/snap.rs index c0e22903..fdf33bc4 100644 --- a/crates/app/src/ui/canvas/snap.rs +++ b/crates/app/src/ui/canvas/snap.rs @@ -90,6 +90,7 @@ pub(crate) fn snap_object_frame( ci: usize, drag: &ObjectDrag, candidate: ObjectFrame, + keep_aspect: bool, ui: &Ui, ) -> (ObjectFrame, Vec) { let alt = ui.input(|i| i.modifiers.alt); @@ -97,8 +98,6 @@ pub(crate) fn snap_object_frame( return (candidate, Vec::new()); } let canvas = &app.doc.canvases[ci]; - let zoom = app.session.board.zoom.max(0.01); - let threshold = SNAP_PX / zoom; let mut targets = SnapTargets::from_page(canvas.size_pt(), &canvas.layout); let moving = |id: ObjectId| id == drag.object || drag.others.iter().any(|(other, _)| *other == id); @@ -115,12 +114,18 @@ pub(crate) fn snap_object_frame( .plot_area(drag.object) .zip(canvas.content_page_frame(drag.object)) .map(|(plot, frame)| PlotInsets::of(frame, plot)); + let gesture = SnapGesture { + kind: drag.kind, + before: drag.before, + keep_aspect, + axes: drag.snap_axes, + zoom: app.session.board.zoom.max(0.01), + }; snap_layout_frame( - drag.kind, + gesture, candidate, &targets, insets.map(|insets| (insets, &plots)), - threshold, ) } @@ -161,8 +166,6 @@ pub(crate) fn snap_panel_content_frame( let Some(panel) = canvas.panel(panel_id) else { return (candidate, Vec::new()); }; - let zoom = app.session.board.zoom.max(0.01); - let threshold = SNAP_PX / zoom; let mut targets = SnapTargets::from_page( [panel.frame.width, panel.frame.height], &plotium_core::layout::PageLayout::default(), @@ -176,7 +179,15 @@ pub(crate) fn snap_panel_content_frame( targets.push_object(frame); } } - let (frame, guides) = snap_layout_frame(drag.kind, candidate, &targets, None, threshold); + // Panel content always resizes aspect-locked. + let gesture = SnapGesture { + kind: drag.kind, + before: drag.before, + keep_aspect: true, + axes: drag.snap_axes, + zoom: app.session.board.zoom.max(0.01), + }; + let (frame, guides) = snap_layout_frame(gesture, candidate, &targets, None); let guides = guides .into_iter() .map(|mut guide| { @@ -208,7 +219,6 @@ pub(crate) fn snap_panel_frame( let Some(canvas) = app.doc.canvases.get(ci) else { return (candidate, Vec::new()); }; - let threshold = SNAP_PX / app.session.board.zoom.max(0.01); let mut targets = SnapTargets::from_page(canvas.size_pt(), &canvas.layout); let moving_panel = |id: PanelId| id == drag.panel || drag.others.iter().any(|(other, _)| *other == id); @@ -224,35 +234,89 @@ pub(crate) fn snap_panel_frame( .panel_plot_area(drag.panel) .zip(canvas.panel(drag.panel)) .map(|(plot, panel)| PlotInsets::of(panel.frame, plot)); + let gesture = SnapGesture { + kind: drag.kind, + before: drag.before, + keep_aspect: false, + axes: drag.snap_axes, + zoom: app.session.board.zoom.max(0.01), + }; snap_layout_frame( - drag.kind, + gesture, candidate, &targets, insets.map(|insets| (insets, &plots)), - threshold, ) } -fn snap_layout_frame( +#[derive(Clone, Copy)] +struct SnapGesture { kind: ObjectDragKind, + before: ObjectFrame, + keep_aspect: bool, + axes: [bool; 2], + zoom: f32, +} + +fn snap_layout_frame( + gesture: SnapGesture, candidate: ObjectFrame, targets: &SnapTargets, plot: layout::PlotSnap, - threshold: f32, ) -> (ObjectFrame, Vec) { - match kind { + let threshold = SNAP_PX / gesture.zoom; + // A target below the drag floor would pull the frame to it. Plot and Panel + // minimums apply after snapping, and clear the guides when they do. + let min_size = if gesture.keep_aspect { + plotium_core::state::aspect_locked_min_size(gesture.before, MIN_OBJECT_SIZE_PT) + } else { + resize_min_size(gesture.before, false) + }; + match gesture.kind { ObjectDragKind::Move => layout::snap_move_with_plot(candidate, targets, plot, threshold), ObjectDragKind::Resize(handle) => layout::snap_resize_with_plot( candidate, - movable_edges(handle), + dragged_edges(handle, gesture.axes), targets, plot, threshold, - MIN_OBJECT_SIZE_PT, + min_size, ), } } +/// Records the axes along which the pointer has left the dead-zone, in screen px. +pub(crate) fn latch_snap_axes(axes: &mut [bool; 2], travel: [f32; 2]) { + axes[0] |= travel[0].abs() > DRAG_START_PX; + axes[1] |= travel[1].abs() > DRAG_START_PX; +} + +/// The handle's edges along `axes`. A resize is live from the press, so an edge +/// near a target would otherwise snap, and with a locked aspect rescale the +/// object, without the pointer travelling that way. +fn dragged_edges(handle: ResizeHandle, axes: [bool; 2]) -> MovableEdges { + let edges = movable_edges(handle); + MovableEdges { + left: edges.left && axes[0], + right: edges.right && axes[0], + top: edges.top && axes[1], + bottom: edges.bottom && axes[1], + } +} + +/// Drops the guides of an axis whose edges a later rescale moved off the snap. +pub(crate) fn retain_landed_guides( + guides: &mut Vec, + snapped: ObjectFrame, + frame: ObjectFrame, +) { + const ROUNDING_PT: f32 = 1e-3; + let same = |a: f32, b: f32| (a - b).abs() <= ROUNDING_PT; + let held_x = same(snapped.x, frame.x) && same(snapped.width, frame.width); + let held_y = same(snapped.y, frame.y) && same(snapped.height, frame.height); + guides.retain(|guide| if guide.vertical { held_x } else { held_y }); +} + pub(crate) fn movable_edges(handle: ResizeHandle) -> MovableEdges { let (left, right) = ( matches!(handle, ResizeHandle::TopLeft | ResizeHandle::BottomLeft), @@ -274,6 +338,126 @@ pub(crate) fn movable_edges(handle: ResizeHandle) -> MovableEdges { mod tests { use super::*; + const ZOOM: f32 = 1.0; + + fn resize( + before: ObjectFrame, + candidate: ObjectFrame, + keep_aspect: bool, + ys: &[f32], + ) -> (ObjectFrame, Vec) { + let mut axes = [false; 2]; + latch_snap_axes( + &mut axes, + [ + candidate.width - before.width, + candidate.height - before.height, + ], + ); + let gesture = SnapGesture { + kind: ObjectDragKind::Resize(ResizeHandle::BottomRight), + before, + keep_aspect, + axes, + zoom: ZOOM, + }; + let mut targets = SnapTargets::default(); + targets.ys.extend_from_slice(ys); + snap_layout_frame(gesture, candidate, &targets, None) + } + + #[test] + fn pressing_a_handle_snaps_nothing() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 20.0); + for keep_aspect in [false, true] { + let (frame, guides) = resize(before, before, keep_aspect, &[24.0]); + assert_eq!(frame, before); + assert!(guides.is_empty()); + } + } + + #[test] + fn an_axis_the_drag_has_not_moved_does_not_snap() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 20.0); + let along_x = ObjectFrame::new(0.0, 0.0, 130.0, 20.0); + assert_eq!(resize(before, along_x, false, &[24.0]).0, along_x); + } + + #[test] + fn the_moved_axis_snaps_as_before() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 80.0); + let candidate = ObjectFrame::new(0.0, 0.0, 100.0, 96.0); + let (frame, guides) = resize(before, candidate, false, &[100.0]); + assert_eq!(frame, ObjectFrame::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(guides.len(), 1); + } + + #[test] + fn a_large_object_skips_a_snap_below_the_minimum() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 80.0); + let candidate = ObjectFrame::new(0.0, 0.0, 100.0, 26.0); + assert_eq!(resize(before, candidate, false, &[20.0]).0, candidate); + } + + #[test] + fn a_thin_edge_snaps_below_the_object_minimum() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 15.0); + let candidate = ObjectFrame::new(0.0, 0.0, 100.0, 21.0); + let (frame, guides) = resize(before, candidate, false, &[18.0]); + assert_eq!(frame.height, 18.0); + assert_eq!(guides.len(), 1); + } + + #[test] + fn an_aspect_locked_shrink_snaps_below_the_object_minimum() { + let kind = ObjectDragKind::Resize(ResizeHandle::BottomRight); + let before = ObjectFrame::new(0.0, 0.0, 100.0, 60.0); + let vertical = ObjectFrame::new(0.0, 0.0, 100.0, 18.0); + let (snapped, mut guides) = resize(before, vertical, true, &[20.0]); + let frame = preserve_aspect_frame(before, snapped, kind); + retain_landed_guides(&mut guides, snapped, frame); + assert_eq!(frame.height, 20.0); + assert_eq!(guides.len(), 1); + assert_eq!(resize(before, vertical, false, &[20.0]).0, vertical); + } + + #[test] + fn an_aspect_locked_resize_skips_a_target_below_its_floor() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 60.0); + let vertical = ObjectFrame::new(0.0, 0.0, 100.0, 18.0); + let (snapped, guides) = resize(before, vertical, true, &[12.0]); + assert_eq!(snapped, vertical); + assert!(guides.is_empty()); + } + + #[test] + fn a_guide_the_aspect_rescale_leaves_is_dropped() { + let kind = ObjectDragKind::Resize(ResizeHandle::BottomRight); + let before = ObjectFrame::new(0.0, 0.0, 100.0, 60.0); + let diagonal = ObjectFrame::new(0.0, 0.0, 30.0, 18.0); + let (snapped, mut guides) = resize(before, diagonal, true, &[20.0]); + assert_eq!(guides.len(), 1); + let frame = preserve_aspect_frame(before, snapped, kind); + retain_landed_guides(&mut guides, snapped, frame); + assert!((frame.height - 18.0).abs() < 1e-3); + assert!(guides.is_empty()); + } + + #[test] + fn an_axis_stays_on_after_the_pointer_returns() { + let mut axes = [false; 2]; + latch_snap_axes(&mut axes, [4.0, 5.0]); + assert_eq!(axes, [false, false]); + latch_snap_axes(&mut axes, [-30.0, 0.0]); + latch_snap_axes(&mut axes, [3.0, 0.0]); + assert_eq!(axes, [true, false]); + + let edges = dragged_edges(ResizeHandle::TopRight, axes); + assert!(edges.right && !edges.top); + let edges = dragged_edges(ResizeHandle::BottomLeft, [false, true]); + assert!(edges.bottom && !edges.left); + } + #[test] fn free_placement_keeps_the_candidate_without_a_magnetic_target() { let others = [PlotRect::new(0.0, 0.0, 100.0, 80.0)]; @@ -372,6 +556,7 @@ mod tests { children: Vec::new(), start_pointer: [before.x, before.y], start_pointer_screen: [0.0, 0.0], + snap_axes: [true; 2], active: true, detached_since: None, } @@ -424,6 +609,22 @@ mod tests { ); } + #[test] + fn pressing_a_panel_handle_snaps_nothing() { + let frame = ObjectFrame::new(40.0, 30.0, 100.0, 20.0); + let (mut app, ids) = app_with_panels(&[frame]); + app.settings.general.snap_enabled = true; + let [width, height] = app.doc.canvases[0].size_pt(); + let close = ObjectFrame::new(width - 103.0, height - 23.0, frame.width, frame.height); + app.doc.canvases[0].panel_mut(ids[0]).unwrap().frame = close; + let kind = ObjectDragKind::Resize(ResizeHandle::BottomRight); + let mut drag = panel_drag(&app, ids[0], kind); + drag.snap_axes = [false; 2]; + let (snapped, guides) = snap_panel_frame(&app, 0, &drag, close, false); + assert_eq!(snapped, close); + assert!(guides.is_empty()); + } + #[test] fn panel_snapping_ignores_group_members_and_alt() { let (app, ids) = app_with_panels(&[ diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 687084b0..0ed9e530 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -46,6 +46,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { before: frame, start_pointer: [0.0, 0.0], start_pointer_screen: [0.0, 0.0], + snap_axes: [false; 2], others: Vec::new(), active: true, space: ObjectDragSpace::Page, @@ -63,6 +64,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { detached_since: None, start_pointer: [0.0, 0.0], start_pointer_screen: [0.0, 0.0], + snap_axes: [false; 2], active: true, }), true, diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs index e3ed4025..6a625928 100644 --- a/crates/core/src/layout.rs +++ b/crates/core/src/layout.rs @@ -163,14 +163,14 @@ pub fn snap_move( } /// Snap a resizing frame by pulling only the moving edges onto targets, keeping -/// the anchored edges fixed. A snap that would shrink the frame below -/// `min_size` in that axis is skipped. +/// the anchored edges fixed. A snap that would leave the frame below that +/// axis's `min_size` is skipped. pub fn snap_resize( frame: ObjectFrame, edges: MovableEdges, targets: &SnapTargets, threshold: f32, - min_size: f32, + min_size: [f32; 2], ) -> (ObjectFrame, Vec) { snap_resize_with_plot(frame, edges, targets, None, threshold, min_size) } @@ -657,7 +657,7 @@ mod tests { right: true, ..MovableEdges::default() }; - let (snapped, guides) = snap_resize(frame, edges, &targets, 6.0, 24.0); + let (snapped, guides) = snap_resize(frame, edges, &targets, 6.0, [24.0; 2]); assert!(approx(snapped.x, 10.0)); assert!(approx(snapped.x + snapped.width, 100.0)); assert_eq!(guides.len(), 1); @@ -720,7 +720,7 @@ mod tests { right: true, ..MovableEdges::default() }; - let (snapped, guides) = snap_resize(frame, edges, &targets, 6.0, 24.0); + let (snapped, guides) = snap_resize(frame, edges, &targets, 6.0, [24.0; 2]); assert!(approx(snapped.width, 25.0)); assert!(guides.is_empty()); } diff --git a/crates/core/src/layout/plot_area.rs b/crates/core/src/layout/plot_area.rs index cbbf4cb6..71b1463e 100644 --- a/crates/core/src/layout/plot_area.rs +++ b/crates/core/src/layout/plot_area.rs @@ -107,14 +107,14 @@ pub fn snap_move_with_plot( /// Snap a resizing frame by pulling only its moving edges (or the plot-area /// edges inside them) onto targets, keeping the anchored edges fixed. A snap -/// that would shrink the frame below `min_size` in that axis is skipped. +/// that would leave the frame below that axis's `min_size` is skipped. pub fn snap_resize_with_plot( frame: ObjectFrame, edges: MovableEdges, targets: &SnapTargets, plot: PlotSnap, threshold: f32, - min_size: f32, + min_size: [f32; 2], ) -> (ObjectFrame, Vec) { let insets = plot.map(|(insets, _)| insets).unwrap_or_default(); let none: &[f32] = &[]; @@ -168,7 +168,7 @@ pub fn snap_resize_with_plot( } else { (lo[axis], hi[axis] + delta) }; - if new_hi - new_lo >= min_size { + if new_hi - new_lo >= min_size[axis] { lo[axis] = new_lo; hi[axis] = new_hi; guides.push(SnapGuide { @@ -234,8 +234,14 @@ mod tests { right: true, ..Default::default() }; - let (snapped, guides) = - snap_resize_with_plot(frame, edges, &targets, Some((insets, &plots)), 6.0, 24.0); + let (snapped, guides) = snap_resize_with_plot( + frame, + edges, + &targets, + Some((insets, &plots)), + 6.0, + [24.0; 2], + ); assert_eq!(snapped, ObjectFrame::new(10.0, 0.0, 198.0, 80.0)); assert_eq!(guides.len(), 1); } diff --git a/crates/core/src/state/content_geometry.rs b/crates/core/src/state/content_geometry.rs index b819d980..68af36b4 100644 --- a/crates/core/src/state/content_geometry.rs +++ b/crates/core/src/state/content_geometry.rs @@ -17,13 +17,22 @@ pub fn aspect_locked_size(before: ObjectFrame, candidate: ObjectFrame, minimum: } else { height_scale }; - let floor = (minimum / before.width.max(before.height)) - .min(1.0) - .max(MIN_CONTENT_FRAME_PT / before.width.min(before.height)); - let scale = scale.max(floor); + let scale = scale.max(aspect_scale_floor(before, minimum)); [before.width * scale, before.height * scale] } +/// The smallest size `aspect_locked_size` can reach from `before`. +pub fn aspect_locked_min_size(before: ObjectFrame, minimum: f32) -> [f32; 2] { + let floor = aspect_scale_floor(before, minimum); + [before.width * floor, before.height * floor] +} + +fn aspect_scale_floor(before: ObjectFrame, minimum: f32) -> f32 { + (minimum / before.width.max(before.height)) + .min(1.0) + .max(MIN_CONTENT_FRAME_PT / before.width.min(before.height)) +} + impl CanvasDocument { /// Only this content and its parent govern eligibility. A locked sibling /// must not prevent an operation that does not move or resize that sibling. @@ -146,6 +155,16 @@ mod tests { assert_eq!(resized([600.0, 2.0], [100.0, 0.5]), [300.0, 1.0]); } + #[test] + fn the_reachable_minimum_matches_the_resize_floor() { + let before = ObjectFrame::new(0.0, 0.0, 100.0, 60.0); + let tiny = ObjectFrame::new(0.0, 0.0, 2.0, 2.0); + assert_eq!( + aspect_locked_min_size(before, DRAG_MINIMUM), + aspect_locked_size(before, tiny, DRAG_MINIMUM) + ); + } + #[test] fn an_edge_under_one_point_rises_to_it() { let before = ObjectFrame::new(0.0, 0.0, 100.0, 40.0); diff --git a/crates/core/src/state/ui_layout_drag.rs b/crates/core/src/state/ui_layout_drag.rs index b794719a..3eb85cab 100644 --- a/crates/core/src/state/ui_layout_drag.rs +++ b/crates/core/src/state/ui_layout_drag.rs @@ -23,6 +23,9 @@ pub struct ObjectDrag { /// click with a few px of jitter selects without nudging the frame. Resize /// grabs are deliberate and start `true`. pub active: bool, + /// Whether the pointer has left the dead-zone along x and y. A resize snaps + /// only along these axes, and an axis stays on once it has moved. + pub snap_axes: [bool; 2], /// Coordinate space in which the live gesture edits the content frame. pub space: ObjectDragSpace, } @@ -50,6 +53,8 @@ pub struct PanelDrag { pub start_pointer: [f32; 2], pub start_pointer_screen: [f32; 2], pub active: bool, + /// See [`ObjectDrag::snap_axes`]. + pub snap_axes: [bool; 2], /// Start of continuous full separation from every canvas, in UI clock seconds. pub detached_since: Option, }