From c36971afe9dd3fc615b81b48b98bdd5755352a70 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:05:08 -0500 Subject: [PATCH] Shape tools: the centre point's cursor comes after the box's handles The Selection tool's cursor now checks the bounding-box handles before a selected object's centre point, as a press does (on a thin box the centre lies within a handle's reach). The shape tools' tests cover a Live Corners widget that lands on another selected shape's centre (the widget rounds), Alt pressed mid-drag (the preview copies and the copy cursor shows), and the engine tests check the copy's place and both gestures' Undo and Redo. The cursor catalog says which arrow a drag of the selection shows. --- crates/engine/src/tests/shapetools.rs | 21 +++++++++-- crates/tools/src/cursors.toml | 4 +- crates/tools/src/select.rs | 11 +++--- crates/tools/src/shape/tests.rs | 53 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 39198966f..8f2b181e2 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -64,14 +64,23 @@ fn a_shape_tool_moves_the_shape_from_its_centre_point() { assert_eq!(undo_steps(&s), steps + 1, "one undo step"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(bounds(&s, id), before); - // Alt-dragging from the centre leaves a copy where the original was. + // Alt-dragging from the centre leaves a copy where the original was, in one undo step. + let steps = undo_steps(&s); gesture( &mut s, Mods { alt: true, ..NONE }, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)], ); - let objects = s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); - assert_eq!(objects, 2, "a copy"); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(); + assert_eq!(objects(&s), 2, "a copy"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step for the copy"); + let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected"); + assert_eq!(bounds(&s, id), before, "the original stays"); + assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 1, "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(objects(&s), 2, "redone"); // Escape turns the drag back, and the release leaves the art alone. let steps = undo_steps(&s); let moved = s.doc().unwrap().selection.objects.clone(); @@ -101,6 +110,12 @@ fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() { assert!(turned.abs() > 1.0, "turned: {turned}"); let centre = bounds(&s, id).center(); assert!((centre - Point::new(150.0, 140.0)).hypot() < 1e-6, "about its centre: {centre:?}"); + // One undo step for the turn, and Redo turns it again. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(angle(&s, id), 0.0, "undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((angle(&s, id) - turned).abs() < 1e-9, "redone"); + s.execute("edit.undo", &json!({})).unwrap(); // A press on a Live Corners widget rounds it, as before (#5477): the widget keeps the corner, // and sits 10 pt inside it. let mut s = new_doc(); diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index aa1f28dc0..c5b0f5232 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -26,7 +26,7 @@ id = "Arrow" group = "Selection" shown_when = [ "The Selection tool over empty canvas or over an unselected object (no Alt).", - "Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).", + "Dragging with the Selection tool (a move), with Direct Selection (an anchor, a segment or a handle), or with a shape tool (from the selection's centre point).", "Any tool with no cursor of its own (Touch Type, Rotate View).", "The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.", ] @@ -94,7 +94,7 @@ id = "Move" group = "Selection" shown_when = [ "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", - "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it (SelectionCopy with Alt, ArrowHollow while it snaps).", + "The shape tools over a selected object's centre point (Attributes › Show Center), before a press there moves it (while the drag runs the arrow is plain, SelectionCopy with Alt, ArrowHollow while it snaps).", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 5a75c2a84..eee24b4e9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1109,11 +1109,6 @@ impl Tool for SelectionTool { if near_selected && pie::over_widget(cx, p) { return Cursor::ArrowHollow; } - // A selected object's centre point (Show Center): a press there moves it, where the art - // may not reach. - if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { - return Cursor::Move; - } if over_rotate_all(cx, p) { return Cursor::Rotate; } @@ -1133,6 +1128,12 @@ impl Tool for SelectionTool { None => {} } } + // A selected object's centre point (Show Center): a press there moves it, where the art + // may not reach. The box's handles take a press before it, so they take the pointer too + // (on a thin box the centre lies within their reach). + if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + return Cursor::Move; + } if let Some(c) = self.guide.cursor(cx, p) { return c; } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 9f45a6630..60c3058e6 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -462,6 +462,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}"); assert!(!t.busy() && !t.transforming(), "{id}"); } + // Alt pressed once the drag has begun copies, and the copy cursor shows, as the Selection + // tool's do. + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 150.0)); + let alt = Mods { alt: true, ..Mods::default() }; + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 150.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["copy"] == json!(true)), "{a:?}"); + assert_eq!(t.cursor(&c, Point::new(170.0, 150.0), alt), Cursor::SelectionCopy); // View › Hide Bounding Box hides the box, not the centre point: a press there still moves. let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) }; let mut t = ShapeTool::new("rectangle"); @@ -497,3 +506,47 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0)); assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); } + +/// Two selected live rectangles, the second placed so that its top-left Live Corners widget (10 pt +/// in along the corner's bisector) lands on the first one's centre point. +fn two_live_rects() -> (vectorcraft_doc::Document, Selection) { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let (mut d, first) = doc_with_live_rect(); + let inside = 10.0 / std::f64::consts::SQRT_2; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((150.0 - inside, 150.0 - inside)), + }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let mut n = Node::path(id, shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(shape)); + } + d.insert(Some(layer), 1, n).unwrap(); + let mut s = Selection::default(); + s.add(first); + s.add(id); + (d, s) +} + +/// A press takes a Live Corners widget before a centre point, as the Selection tool does: with two +/// shapes selected, one's corner widget may sit on another's centre, and it rounds rather than +/// moving the selection. +#[test] +fn a_live_corner_widget_wins_over_another_shapes_centre_point() { + let (d, s) = two_live_rects(); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(150.0, 150.0); + assert!(corners::over_widget(&c, at, false), "a widget is on the centre point"); + assert!(hit_center(&c, at, c.point_tol()).is_some(), "and so is the centre point"); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::CornerRadius, "the widget takes the pointer"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty()); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); +}