From adbcb3a224bad5a82fb3d7a4bea8611b12d87764 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:11:35 -0500 Subject: [PATCH 1/9] Shape tools: the selection moves from its centre point and turns outside a corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shape just drawn keeps its bounding box with the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools, but its centre point (Attributes › Show Center) did nothing and the box's handles only drew: a press anywhere started a new shape, and with the Selection tool a press on the centre of an unfilled shape started a marquee. - The Selection tool now takes a press on a selected object's centre point as the start of a move (held by that point, so it snaps and Alt copies), as Direct Selection has since #4891. A click there still makes the object the key object of a multi-selection. - The shape tools take it too, and take a press in the rotate zone just outside a bounding-box corner (the Selection tool's own zone and cursor) as the start of a rotation about the box's centre. Live Corners widgets keep the corners themselves, so a press on a corner still rounds it; every other press still draws a new shape. Escape cancels, as for the Selection tool's drags. `hit_center` moves from direct.rs to select.rs, shared by the three tools. --- crates/tools/src/cursors.toml | 5 +- crates/tools/src/direct.rs | 12 +- crates/tools/src/lib.rs | 22 ++++ crates/tools/src/select.rs | 32 +++++- crates/tools/src/select/tests.rs | 30 +++++ crates/tools/src/shape.rs | 186 +++++++++++++++++++++++++++++-- crates/tools/src/shape/tests.rs | 68 +++++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 5 +- 9 files changed, 336 insertions(+), 27 deletions(-) diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 0ac3e610a..7071f24e8 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -94,6 +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.", "Reshape over a path or while dragging.", "Inside the Free Transform and Crop Image boxes.", "Puppet Warp over a pin.", @@ -376,7 +377,9 @@ look = "An arc with an arrowhead at each end. RotateToward is the same arc, turn [[cursor]] id = "RotateToward" group = "Transform and resize" -shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."] +shown_when = [ + "Selection, Free Transform and the shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn.", +] hotspot = "Below the arc, in its empty middle, as for Rotate." look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at." diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 3c128d0a1..2b2f04c83 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -60,7 +60,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; -use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; +use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] @@ -530,16 +530,6 @@ fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Po pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) } -/// The selected object whose centre point (Attributes panel › Show Center), drawn while it is -/// selected, is within `tol` of `p`: (object, where the point is). -fn hit_center(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Point)> { - let (symbols, editable) = (cx.doc.symbol_boxes(), cx.doc.editable()); - cx.selection.objects.iter().copied().filter(|id| editable(*id) && in_scope(cx.doc, cx.isolation, *id)).find_map(|id| { - let c = cx.doc.node(id).filter(|n| n.shows_center())?.geometric_bounds_with(&symbols)?.center(); - (c.distance(p) <= tol).then_some((id, c)) - }) -} - /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec)> { anchor_owners(cx, is_area_type).into_iter().find_map(|id| { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee00fdbe9..c6883c3fd 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -348,6 +348,28 @@ pub(crate) mod testutil { (d, id) } + /// A live 100 × 100 rectangle at (100, 100)–(200, 200) with no paint: its centre point + /// (Attributes › Show Center) is at (150, 150), and a press in the middle of it hits no art. + pub fn doc_with_live_rect() -> (Document, NodeId) { + use vectorcraft_doc::{LiveShape, NodeKind}; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = Document::new(500.0, 500.0); + let (l, 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(l), 0, n).unwrap(); + (d, id) + } + /// [`doc_with_rect`] plus a clip group: a filled 200 × 200 square (350, 350)–(550, 550) /// clipped to an unpainted rectangle (400, 400)–(500, 500), so the art lies over the mask's /// edges. The group, the clipping path and the art. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 4bfd6a860..fd6cc0f5c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -469,10 +469,23 @@ pub fn selection_box(cx: &ToolContext) -> Option { /// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected /// on their own): its handles take a press only then. A path the Pen is drawing keeps its end /// anchor selected, so Cmd/Ctrl lending the Selection tool doesn't scale it by hidden handles (#4980). -fn shown_box(cx: &ToolContext) -> Option { +/// The shape tools press it too (#5477). +pub(crate) fn shown_box(cx: &ToolContext) -> Option { (cx.show_bbox && cx.selection.anchors.is_empty()).then(|| selection_box(cx)).flatten() } +/// The selected object whose centre point (Attributes panel › Show Center), drawn while it is +/// selected, is within `tol` of `p`: (object, where the point is). A press there grabs the object +/// by that point — it moves the selection even where no art is painted (an unfilled shape) — as +/// Direct Selection's does (#4891). +pub(crate) fn hit_center(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Point)> { + let (symbols, editable) = (cx.doc.symbol_boxes(), cx.doc.editable()); + cx.selection.objects.iter().copied().filter(|id| editable(*id) && crate::direct::in_scope(cx.doc, cx.isolation, *id)).find_map(|id| { + let c = cx.doc.node(id).filter(|n| n.shows_center())?.geometric_bounds_with(&symbols)?.center(); + (c.distance(p) <= tol).then_some((id, c)) + }) +} + /// The bounds of object `id`, measured as [`selection_bounds`] measures the selection. fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { with_object_bounds(cx, |bounds| bounds(id)) @@ -822,6 +835,18 @@ impl Tool for SelectionTool { self.state = State::Idle; return out; } + // 2b. A selected object's centre point (Attributes › Show Center), drawn while it + // is selected: a drag from it moves the selection, also where no art is painted + // (an unfilled shape), which a press there would otherwise take for a marquee. A + // click there is a click on that object, so it makes it the key object too. + if !m.shift + && !m.cmd + && let Some((id, center)) = hit_center(cx, p, cx.point_tol()) + { + let key = ((cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.alt).then_some(id); + self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect: None, key, exclude: None }; + return vec![]; + } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). if m.cmd && cx.select_behind @@ -1071,6 +1096,11 @@ 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; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 714975bb0..027bfb203 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2139,3 +2139,33 @@ fn a_double_click_on_a_symbol_instance_edits_its_symbol() { assert_eq!(double(&d, Some(l), 300.0, 300.0), vec![]); assert_eq!(double(&d, Some(l), 450.0, 50.0), vec![Action::Exec("object.exitIsolation".into(), json!({}))]); } + +/// #4891 for the Selection tool: a press on a selected object's centre point (Attributes › Show +/// Center) moves the selection, also where no art is painted (an unfilled shape), which the press +/// would otherwise take for a marquee; anywhere else a press still starts one. +#[test] +fn a_press_on_the_centre_point_moves_an_unfilled_shape() { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + assert!( + matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.transform" && v["matrix"] == json!([1.0, 0.0, 0.0, 1.0, 20.0, 10.0])), + "{a:?}" + ); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit]); + // Empty canvas away from it: a marquee, as before. + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Arrow); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0)); + assert!(a.is_empty(), "{a:?}"); + assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index f48625a6a..b1d2a8558 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -10,15 +10,19 @@ //! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle, //! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at //! once: a drag on a live rectangle's or polygon's corner widget rounds its corners and a drag on -//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press anywhere -//! else draws a new shape (#5477). +//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press on the +//! selection's centre point (Show Center) moves it, and one just outside a corner of its box turns +//! it about that centre, as the Selection tool's rotate zone does; a press anywhere else draws a +//! new shape (#5477). use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::bbox::{in_rotate_zone, move_delta, rotate_for_drag}; use crate::corners::{self, CornerDrag}; use crate::guides::{DrawSnap, Leave, square}; use crate::pie::{self, PieDrag}; +use crate::select::{MoveSnap, hit_center, matrix_json, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -26,6 +30,10 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; +/// How far (screen points) outside a corner of the selection's box a press with a shape tool turns +/// the selection, as the Selection tool's rotate zone does. +const ROTATE_ZONE: f64 = 18.0; + /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -38,6 +46,16 @@ enum WidgetDrag { Pie(PieDrag), } +/// A drag of the selection itself, taken by a shape tool: the centre point moves it and the zone +/// just outside a corner of its box turns it, as the Selection tool's drags do (#5477). +enum SelectionDrag { + /// Moving the selection by the centre point `hold`: `start` is where the press was, and the + /// move has begun once the pointer left the drag threshold. + Move { start: Point, hold: Point, began: bool, snap: Box }, + /// Turning the selection about `center` from where the press was. + Rotate { center: Point, start: Point }, +} + pub struct ShapeTool { id: &'static str, start: Option, @@ -59,6 +77,12 @@ pub struct ShapeTool { snap: DrawSnap, /// A widget of the selection is being dragged instead of a shape drawn. widget: Option, + /// The selection is being moved or turned instead of a shape drawn. + selection: Option, + /// The Smart Guides a move of the selection shows. + guides: Vec, + /// The measurement label of a move or a turn: where it goes and what it reads. + measure: Option<(Point, String)>, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -95,6 +119,9 @@ impl ShapeTool { line_angle: 0.0, fill_line: false, widget: None, + selection: None, + guides: vec![], + measure: None, } } @@ -112,6 +139,101 @@ impl ShapeTool { shows_shape_widgets(self.id) && (corners::over_widget(cx, p, false) || pie::over_widget(cx, p)) } + /// The selection's box, while this tool shows it (the tools that draw a box, with a selection + /// and no anchors picked on their own). + fn box_of(&self, cx: &ToolContext) -> Option { + shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten() + } + + /// Is `p` just outside a corner of the selection's box, where a press turns the selection about + /// its centre ([`ROTATE_ZONE`])? On the corner itself the Live Corners widget rounds it (#5477). + fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option { + let bx = self.box_of(cx)?; + in_rotate_zone(bx.rect, bx.to_local(p), cx.tol(5.0), cx.tol(ROTATE_ZONE)).map(|_| bx.center()) + } + + /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press + /// moves the selection? The point shows whatever View › Show Bounding Box says, so the press + /// takes it too. + fn over_center(&self, cx: &ToolContext, p: Point) -> bool { + shows_shape_widgets(self.id) && hit_center(cx, p, cx.point_tol()).is_some() + } + + /// A press on the selection's centre point or just outside a corner of its box: the drag moves + /// or turns the selection instead of drawing a new shape (#5477). A turn begins with the press, + /// as the Selection tool's does; a move begins once the pointer leaves the drag threshold. + fn press_selection(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + if !shows_shape_widgets(self.id) { + return None; + } + self.selection = match hit_center(cx, ev.pos, cx.point_tol()) { + Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), + None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }), + }; + self.selection.as_ref()?; + self.snap.clear(); + self.guides.clear(); + self.measure = None; + Some(match self.selection { + Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())], + _ => vec![], + }) + } + + /// The pointer and keys while the selection is moved or turned: `None` when it is not. + fn selection_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { + let d = self.selection.as_mut()?; + let out = match (ev.kind, d) { + (PointerKind::Drag, SelectionDrag::Move { start, hold, began, snap }) => { + let mut out = vec![]; + if !*began { + if ev.pos.distance(*start) < cx.tol(3.0) { + return Some(out); + } + *began = true; + out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); + } + let shift = ev.mods.shift; + let (delta, guides) = if shift { + snap.snap_along(cx, *hold, move_delta(*start, ev.pos, true, cx.constrain_angle), ev.mods.alt) + } else { + snap.snap(cx, *hold, ev.pos - *start, ev.mods.alt) + }; + self.guides = guides; + self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y))); + out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt}))); + out + } + (PointerKind::Drag, SelectionDrag::Rotate { center, start }) => { + let (a, deg) = rotate_for_drag(*center, *start, ev.pos, ev.mods.shift); + self.measure = cx.transform_tools_guides.then(|| (ev.pos, format!("{:.1}°", -deg))); + vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(a), "copy": false}))] + } + (PointerKind::Up, SelectionDrag::Move { began, .. }) => { + let began = *began; + self.selection = None; + self.guides.clear(); + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Up, SelectionDrag::Rotate { .. }) => { + self.selection = None; + self.measure = None; + vec![Action::Commit] + } + _ => vec![], + }; + Some(out) + } + + /// Escape during a drag of the selection turns it back, as the Selection tool's does. + fn cancel_selection(&mut self) -> Vec { + self.selection = None; + self.guides.clear(); + self.measure = None; + vec![Action::Cancel] + } + /// The pointer and keys while a widget is dragged: `None` when none is. fn widget_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { let w = self.widget.as_mut()?; @@ -268,16 +390,19 @@ impl Tool for ShapeTool { self.id } fn busy(&self) -> bool { - self.start.is_some() || self.widget.is_some() + self.start.is_some() || self.widget.is_some() || self.selection.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.widget_pointer(cx, ev) { return out; } + if let Some(out) = self.selection_pointer(cx, ev) { + return out; + } match ev.kind { PointerKind::Move => { // Over a widget a press would drag, no Smart Guide for a new shape's start. - if self.over_widget(cx, ev.pos) { + if self.over_widget(cx, ev.pos) || self.over_center(cx, ev.pos) || self.over_rotate(cx, ev.pos).is_some() { self.snap.clear(); } else { self.snap.hover(cx, ev.pos, &[], None); @@ -289,6 +414,9 @@ impl Tool for ShapeTool { self.snap.clear(); return vec![]; } + if let Some(out) = self.press_selection(cx, ev) { + return out; + } let p = self.snap.press(cx, ev.pos, &[], None); self.start = Some(p); self.last = ev.pos; @@ -340,6 +468,10 @@ impl Tool for ShapeTool { } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + // A drag of the selection: Escape turns it back, as with the Selection tool. + if self.selection.is_some() { + return if key == ToolKey::Escape { self.cancel_selection() } else { vec![] }; + } // A widget drag: Up and Down step a corner's kind; Escape cancels it, as with the // Selection tool. if let Some(w) = &mut self.widget { @@ -399,11 +531,19 @@ impl Tool for ShapeTool { match &self.widget { Some(WidgetDrag::Corner(c)) => c.adjusting(), Some(WidgetDrag::Pie(d)) => d.adjusting(), - None => false, + None => match &self.selection { + Some(SelectionDrag::Move { began, .. }) => *began, + Some(SelectionDrag::Rotate { .. }) => true, + None => false, + }, } } fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - matches!(self.widget, Some(WidgetDrag::Corner(_))) && matches!(key, ToolKey::Up | ToolKey::Down) + match (&self.widget, &self.selection) { + (Some(WidgetDrag::Corner(_)), _) => matches!(key, ToolKey::Up | ToolKey::Down), + (_, Some(_)) => key == ToolKey::Escape, + _ => false, + } } fn overlays(&self, cx: &ToolContext) -> Vec { match &self.widget { @@ -412,6 +552,10 @@ impl Tool for ShapeTool { None => {} } let mut o = self.snap.guides().to_vec(); + o.extend(self.guides.iter().cloned()); + if let Some((p, text)) = &self.measure { + o.push(Overlay::Measure { p: *p, text: text.clone() }); + } if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) { let p = self.last + Vec2::new(cx.tol(12.0), cx.tol(12.0)); let text = if self.id == "lineSegment" { @@ -446,12 +590,32 @@ impl Tool for ShapeTool { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); match &self.widget { - Some(WidgetDrag::Corner(_)) => Cursor::CornerRadius, - Some(WidgetDrag::Pie(_)) => Cursor::ArrowHollow, - None if idle && shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) => Cursor::CornerRadius, - None if idle && self.over_widget(cx, p) => Cursor::ArrowHollow, - None => Cursor::Crosshair, + Some(WidgetDrag::Corner(_)) => return Cursor::CornerRadius, + Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow, + None => {} } + match &self.selection { + Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(_) => return Cursor::Arrow, + None => {} + } + // The selection's centre point moves it and the zone just outside a corner turns it; + // everywhere else a press draws a new shape. + if idle { + if self.over_center(cx, p) { + return Cursor::Move; + } + if let Some(center) = self.over_rotate(cx, p) { + return Cursor::rotate_about_in_view(center, p, cx.view_angle()); + } + if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { + return Cursor::CornerRadius; + } + if self.over_widget(cx, p) { + return Cursor::ArrowHollow; + } + } + Cursor::Crosshair } fn options(&self) -> Value { json!({ diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index f51cf117f..e53cea057 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -423,3 +423,71 @@ fn a_shape_tool_drags_the_selected_shapes_widgets() { assert!(matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.setLiveShape" && v.get("pieEnd").is_some()), "{a:?}"); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 20.0)), vec![Action::Commit]); } + +/// A selected live 100 × 100 rectangle at (100, 100)–(200, 200) with no paint: its centre point +/// (Attributes › Show Center) is at (150, 150) and its corners at the box's. +fn unfilled_live_rect() -> (vectorcraft_doc::Document, Selection) { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + (d, s) +} + +/// #5477: with a shape tool the selection's centre point moves it and the zone just outside a +/// corner of its box turns it, as the Selection tool's do; anywhere else a press draws a new +/// shape. The centre is where no art is painted, so without this a press there would draw one. +#[test] +fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner() { + let (d, s) = unfilled_live_rect(); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] { + let mut t = ShapeTool::new(id); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move, "{id}"); + // No Smart Guide for a new shape's start over the centre point. + t.pointer(&c, &PointerEvent::new(PointerKind::Move, centre.x, centre.y)); + assert!(t.overlays(&c).is_empty(), "{id}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)).is_empty(), "{id}"); + assert!(t.busy(), "{id}"); + // A drag moves the selection by the pointer's offset. + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{id}: {a:?}"); + assert!(t.transforming(), "{id}"); + assert_eq!( + a.last(), + Some(&Action::Preview("object.transform".into(), json!({"matrix": [1.0, 0.0, 0.0, 1.0, 20.0, 10.0], "copy": false}))), + "{id}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 160.0)), vec![Action::Commit], "{id}"); + assert!(!t.busy() && !t.transforming(), "{id}"); + } + // 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"); + assert_eq!(t.cursor(&hidden, centre, Mods::default()), Cursor::Move); + t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); + let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + // Just outside the bottom-right corner (7.07 pt away, past the corner widget's 5): a press + // there turns the selection about its centre. + let outside = Point::new(205.0, 205.0); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, outside, Mods::default()), Cursor::rotate_about(centre, outside)); + // A turn begins with the press, as the Selection tool's does (its drags preview into it). + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, outside.x, outside.y)), vec![Action::Begin("Rotate".into())]); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 205.0)); + let (want, _) = rotate_for_drag(centre, outside, Point::new(150.0, 205.0), false); + assert_eq!(a.last(), Some(&Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))), "{a:?}"); + assert!(t.transforming()); + // Escape turns it back. + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); + // Away from the centre point and the corners, the tool draws a new shape. + let away = Point::new(300.0, 300.0); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, away, Mods::default()), Cursor::Crosshair); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, away.x, away.y)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0)); + assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8761aad38..1a9c2c2c7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid, #3835; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1721,6 +1721,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | +| 2026-11-06 | minor | Shape tools: a shape just drawn can be moved and turned with its tool still active — a press on the selection's centre point (Attributes › Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with its rotate cursor, as the Selection tool's do (#5477); the Selection tool takes the centre point too, so a press there moves an unfilled shape instead of starting a marquee; evidence: `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` (`crates/tools/src/shape/tests.rs`) and `a_press_on_the_centre_point_moves_an_unfilled_shape` (`select/tests.rs`); scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar (each field's scrub label was a second tab stop, invisible because only the label is drawn there, and the W/H link between W and H in the Control bar was one too, and now comes after the fields); evidence: `one_tab_moves_from_a_field_to_the_next` and `the_control_bar_link_toggles_by_click` in `crates/ui-egui/src/panels/tests_constrain.rs`; scores and estimates unchanged | | 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a62d8588b..5ee5c72dc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; Transform fields: one Tab per field, the W/H link after them; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; Transform fields: one Tab per field; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -45,6 +45,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: spinner arrows beside Opacity, the offsets, Blur and Darkness (#5367, #5368); scores and estimates unchanged | | 2026-11-06 | minor | Rename (`ui.rename`, no default key) opens the Layers panel's name field on the selected layer, group or object's row, as double-clicking its name does; a key can be assigned in Keyboard Shortcuts (#5324, a VectorCraft addition); scores and estimates unchanged | | 2026-11-06 | minor | Properties panel in Edit Artboards (Shift+O): a Transform section with the shared reference point, X, Y, W and H, the preset and orientation, and Move and Scale Artwork with Artboard, above the Artboards section with the name, Artboard Options and Fill (#5219); scores and estimates unchanged | +| 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | @@ -192,7 +193,7 @@ tools, never checked side by side) isn't captured row by row. | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | -| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels) and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Test `a_shape_tool_drags_the_selected_shapes_widgets` | +| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)); locking or hiding the layer (or a group) around the isolated object afterwards keeps clicks and marquees off it, as outside isolation mode (#5309) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | From 1c345d8e6a04136b848075ee2c87550bc31bd4b4 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:29:19 -0500 Subject: [PATCH 2/9] Shape tools: drawing another shape from the selected one's centre moves it A press on the centre point of the selected shape now moves the selection, as asked (#5477), so the Constrain Angle test's repeated draws from the same point moved the polygon drawn before them. It deselects first: nothing selected, the press draws. --- crates/engine/src/tests/constrain_new.rs | 3 +++ docs/ui-parity.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/constrain_new.rs b/crates/engine/src/tests/constrain_new.rs index 4184cb3c9..382e3af31 100644 --- a/crates/engine/src/tests/constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -113,6 +113,9 @@ fn constrain_angle_turns_the_base_orientation_of_polygons_and_stars() { let shift = Mods { shift: true, ..Mods::default() }; let draw = |s: &mut Session, tool: &str, deg: f64| { set_angle(s, deg); + // Nothing selected: the press draws, rather than moving the shape drawn before it by the + // centre point it shows (#5477). + s.execute("select.none", &json!({})).unwrap(); s.select_tool(tool, ViewInfo::default()).unwrap(); gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 130.0, 140.0), (Drag, 130.0, 140.0), (Up, 130.0, 140.0)]); last_id(s) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5ee5c72dc..1062d89fd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -193,7 +193,7 @@ tools, never checked side by side) isn't captured row by row. | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | -| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws. Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | +| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)); locking or hiding the layer (or a group) around the isolated object afterwards keeps clicks and marquees off it, as outside isolation mode (#5309) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | From cd88b15912962ae93311dcbd1bc21bce13bc43df Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:56:24 -0500 Subject: [PATCH 3/9] Shape tools: review follow-ups (Shift, handle boundary, cursor order, feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Selection tool takes a Shift-press on a selected object's centre point too: a Shift-drag moves the selection held to 45° steps, and a Shift-click there takes the object out of the selection, as a Shift-press on the object does. - One rotate zone for the Selection tool and the shape tools (`select::rotate_zone`): a press on a bounding-box handle is not a turn, so the shape tools' zone matches the Selection tool's classification exactly. - The shape tools' hover cursor follows the press order (corner and pie widgets, then the centre point, then the rotate zone), and a move of the selection shows the Selection tool's feedback: SelectionCopy with Alt, ArrowHollow while it snaps. - Engine-level tests for the whole gesture (`crates/engine/src/tests/shapetools.rs`): repeated previews and one undo step, Alt copy, Escape then release, a turn about the centre, the corner widget still rounding, and an unfilled shape moved by the Selection tool. - docs: evidence in target-app-parity.md's Shapes bullet and the M2 milestone, and the Move cursor's entry names the copy and snap cursors. --- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/shapetools.rs | 133 ++++++++++++++++++++++++++ crates/tools/src/cursors.toml | 2 +- crates/tools/src/select.rs | 25 +++-- crates/tools/src/select/tests.rs | 30 ++++++ crates/tools/src/shape.rs | 54 ++++++----- crates/tools/src/shape/tests.rs | 12 ++- docs/roadmap.md | 1 + docs/target-app-parity.md | 2 +- 9 files changed, 225 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/tests/shapetools.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 812a6282f..862bc4b77 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -147,6 +147,7 @@ mod scalestrokes; mod scripts; mod select_ids_first; mod shaper; +mod shapetools; mod shortcutset; mod simplify_part; mod slices; diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs new file mode 100644 index 000000000..39198966f --- /dev/null +++ b/crates/engine/src/tests/shapetools.rs @@ -0,0 +1,133 @@ +//! Shape tools: the selection they keep on show moves by its centre point (Attributes › Show +//! Center) and turns from the rotate zone just outside a corner of its box, as the Selection +//! tool's do, through the whole engine (#5477). + +use serde_json::json; +use vectorcraft_doc::{LiveShape, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..ViewInfo::default() } +} + +fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap(); + } +} + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; + +/// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still +/// selected: its centre point is at (150, 140). +fn drawn_rect(s: &mut Session) -> NodeId { + s.select_tool("rectangle", view()).unwrap(); + gesture(s, NONE, &[(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 200.0, 180.0), (PointerKind::Up, 200.0, 180.0)]); + *s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected") +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds") +} + +fn angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle) +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// With the Rectangle tool still active, a drag from the shape's centre point moves it, in one +/// undo step; Alt-dragging it copies it; Escape turns a drag back. +#[test] +fn a_shape_tool_moves_the_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + let before = bounds(&s, id); + let steps = undo_steps(&s); + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Drag, 180.0, 170.0), (PointerKind::Up, 180.0, 170.0)], + ); + assert_eq!(bounds(&s, id), before + Vec2::new(30.0, 30.0), "moved by the drag"); + 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. + 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"); + // 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(); + let copy = *moved.first().expect("the copy is selected"); + let at = bounds(&s, copy); + gesture(&mut s, NONE, &[(PointerKind::Down, 200.0, 140.0), (PointerKind::Drag, 250.0, 190.0)]); + s.tool_key(ToolKey::Escape, NONE, view()).unwrap(); + gesture(&mut s, NONE, &[(PointerKind::Up, 250.0, 190.0)]); + assert_eq!(undo_steps(&s), steps, "no step"); + assert_eq!(s.doc().unwrap().selection.objects, moved); + assert_eq!(bounds(&s, copy), at, "back where it was"); +} + +/// A press in the rotate zone just outside a corner of the shape's box turns it about that box's +/// centre, as the Selection tool's does; a press on the corner itself still rounds it. +#[test] +fn a_shape_tool_turns_the_shape_from_just_outside_a_corner() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + // 8.49 pt outside the top-left corner (100, 100), past the handle's 5 pt square. + gesture( + &mut s, + NONE, + &[(PointerKind::Down, 94.0, 94.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Drag, 150.0, 70.0), (PointerKind::Up, 150.0, 70.0)], + ); + let turned = angle(&s, id); + 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:?}"); + // 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(); + let id = drawn_rect(&mut s); + gesture(&mut s, NONE, &[(PointerKind::Down, 110.0, 110.0), (PointerKind::Drag, 125.0, 125.0), (PointerKind::Up, 125.0, 125.0)]); + let n = s.doc().unwrap().doc.node(id).expect("the rectangle"); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) }; + let LiveShape::Rectangle { radii, .. } = &**live else { panic!("a rectangle") }; + assert!(radii[0] > 0.0, "the corner rounded: {radii:?}"); + assert_eq!(angle(&s, id), 0.0, "rounding does not turn it"); +} + +/// The Selection tool takes the centre point of an unfilled shape too: a drag there moves it +/// instead of starting a marquee. +#[test] +fn the_selection_tool_moves_an_unfilled_shape_from_its_centre_point() { + let mut s = new_doc(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", view()).unwrap(); + let before = bounds(&s, id); + gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 160.0), (PointerKind::Up, 170.0, 160.0)]); + assert_eq!(bounds(&s, id), before + Vec2::new(20.0, 20.0)); + // Away from it, a drag is still a marquee. + let steps = undo_steps(&s); + gesture(&mut s, NONE, &[(PointerKind::Down, 300.0, 300.0), (PointerKind::Drag, 350.0, 350.0), (PointerKind::Up, 350.0, 350.0)]); + assert_eq!(undo_steps(&s), steps, "a marquee changes no art"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the marquee took nothing"); +} diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 7071f24e8..aa1f28dc0 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -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.", + "The shape tools over a selected object's centre point (Attributes › Show Center), and while dragging it (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 fd6cc0f5c..5a75c2a84 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -527,13 +527,25 @@ enum BoxHit { Rotate, } +/// How far (screen points) outside a corner of the bounding box a press turns the selection: the +/// Selection tool's rotate zone, and the shape tools' (#5477). +pub(crate) const ROTATE_ZONE: f64 = 18.0; + +/// Where a press at `p` just outside a corner of box `b` turns the selection about its centre, as +/// the Selection tool's rotate zone does: the box's centre. None on a handle, on the corner itself +/// or away from every corner. +pub(crate) fn rotate_zone(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { + let (tol, lp) = (cx.tol(5.0), b.to_local(p)); + (hit_handle(b.rect, lp, tol).is_none() && in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).is_some()).then(|| b.center()) +} + /// The bounding-box handle or rotate zone under `p`. fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { let (tol, lp) = (cx.tol(5.0), b.to_local(p)); if let Some(h) = hit_handle(b.rect, lp, tol) { return Some(BoxHit::Handle(h)); } - in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) + in_rotate_zone(b.rect, lp, tol, cx.tol(ROTATE_ZONE)).map(|_| BoxHit::Rotate) } /// Whether the current selection's bounding-box handle is under `p`, using the same geometry as @@ -838,13 +850,14 @@ impl Tool for SelectionTool { // 2b. A selected object's centre point (Attributes › Show Center), drawn while it // is selected: a drag from it moves the selection, also where no art is painted // (an unfilled shape), which a press there would otherwise take for a marquee. A - // click there is a click on that object, so it makes it the key object too. - if !m.shift - && !m.cmd + // click there is a click on that object: it makes it the key object of several, + // and a Shift-click takes it out of the selection on release. + if !m.cmd && let Some((id, center)) = hit_center(cx, p, cx.point_tol()) { - let key = ((cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.alt).then_some(id); - self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect: None, key, exclude: None }; + let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty(); + let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) }; + self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None }; return vec![]; } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 027bfb203..7268d0678 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2169,3 +2169,33 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { assert!(a.is_empty(), "{a:?}"); assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); } + +/// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, +/// and a Shift-click there (no drag) takes the object out of the selection, as a Shift-press on +/// the object itself does. +#[test] +fn a_shift_press_on_the_centre_point_moves_or_deselects() { + let (d, id) = doc_with_live_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let centre = Point::new(150.0, 150.0); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0).with_mods(shift)); + assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); + let Some(Action::Preview(cmd, v)) = a.last().filter(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")) else { panic!("{a:?}") }; + assert_eq!(cmd, "object.transform"); + let [_, _, _, _, dx, dy] = [0, 1, 2, 3, 4, 5].map(|i| v["matrix"][i].as_f64().unwrap_or_default()); + assert!(dx != 0.0 && dx == dy, "held to 45°: {dx} {dy}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0).with_mods(shift)), vec![Action::Commit]); + // A Shift-click there takes the object out of the selection on release. + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y).with_mods(shift)).is_empty()); + assert_eq!( + t.pointer(&c, &ev(PointerKind::Up, centre.x, centre.y).with_mods(shift)), + vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))] + ); +} diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index b1d2a8558..d513b1f5e 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -18,11 +18,11 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::bbox::{in_rotate_zone, move_delta, rotate_for_drag}; +use crate::bbox::{move_delta, rotate_for_drag}; use crate::corners::{self, CornerDrag}; use crate::guides::{DrawSnap, Leave, square}; use crate::pie::{self, PieDrag}; -use crate::select::{MoveSnap, hit_center, matrix_json, shown_box}; +use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -30,10 +30,6 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; -/// How far (screen points) outside a corner of the selection's box a press with a shape tool turns -/// the selection, as the Selection tool's rotate zone does. -const ROTATE_ZONE: f64 = 18.0; - /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -83,6 +79,9 @@ pub struct ShapeTool { guides: Vec, /// The measurement label of a move or a turn: where it goes and what it reads. measure: Option<(Point, String)>, + /// The move of the selection snapped to a point, a guide or the grid at its last step: the + /// arrow turns hollow, as the Selection tool's does. + snapped: bool, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -122,6 +121,7 @@ impl ShapeTool { selection: None, guides: vec![], measure: None, + snapped: false, } } @@ -145,11 +145,11 @@ impl ShapeTool { shows_shape_widgets(self.id).then(|| shown_box(cx)).flatten() } - /// Is `p` just outside a corner of the selection's box, where a press turns the selection about - /// its centre ([`ROTATE_ZONE`])? On the corner itself the Live Corners widget rounds it (#5477). + /// Is `p` in the zone just outside a corner of the selection's box, where a press turns the + /// selection about its centre ([`crate::select::rotate_zone`])? On the corner itself the Live + /// Corners widget rounds it (#5477). fn over_rotate(&self, cx: &ToolContext, p: Point) -> Option { - let bx = self.box_of(cx)?; - in_rotate_zone(bx.rect, bx.to_local(p), cx.tol(5.0), cx.tol(ROTATE_ZONE)).map(|_| bx.center()) + rotate_zone(cx, &self.box_of(cx)?, p) } /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press @@ -194,11 +194,11 @@ impl ShapeTool { out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); } let shift = ev.mods.shift; - let (delta, guides) = if shift { - snap.snap_along(cx, *hold, move_delta(*start, ev.pos, true, cx.constrain_angle), ev.mods.alt) - } else { - snap.snap(cx, *hold, ev.pos - *start, ev.mods.alt) - }; + let unsnapped = move_delta(*start, ev.pos, shift, cx.constrain_angle); + let (delta, guides) = + if shift { snap.snap_along(cx, *hold, unsnapped, ev.mods.alt) } else { snap.snap(cx, *hold, unsnapped, ev.mods.alt) }; + // Shift has already constrained the move: any change is a snap. + self.snapped = (delta - unsnapped).hypot() > 1e-9; self.guides = guides; self.measure = cx.measurement_labels.then(|| (ev.pos, cx.offset_label(delta.x, delta.y))); out.push(Action::Preview("object.transform".into(), json!({"matrix": matrix_json(Affine::translate(delta)), "copy": ev.mods.alt}))); @@ -214,6 +214,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, SelectionDrag::Rotate { .. }) => { @@ -231,6 +232,7 @@ impl ShapeTool { self.selection = None; self.guides.clear(); self.measure = None; + self.snapped = false; vec![Action::Cancel] } @@ -586,7 +588,7 @@ impl Tool for ShapeTool { } o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); match &self.widget { @@ -594,26 +596,30 @@ impl Tool for ShapeTool { Some(WidgetDrag::Pie(_)) => return Cursor::ArrowHollow, None => {} } + // Alt-dragging the selection leaves a copy behind; otherwise the arrow, hollow while the + // move snaps, as the Selection tool's. match &self.selection { Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy, + Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, Some(_) => return Cursor::Arrow, None => {} } - // The selection's centre point moves it and the zone just outside a corner turns it; - // everywhere else a press draws a new shape. + // A press takes the corner and pie widgets first, then the centre point, then the zone + // just outside a corner; everywhere else it draws a new shape. if idle { - if self.over_center(cx, p) { - return Cursor::Move; - } - if let Some(center) = self.over_rotate(cx, p) { - return Cursor::rotate_about_in_view(center, p, cx.view_angle()); - } if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { return Cursor::CornerRadius; } if self.over_widget(cx, p) { return Cursor::ArrowHollow; } + if self.over_center(cx, p) { + return Cursor::Move; + } + if let Some(center) = self.over_rotate(cx, p) { + return Cursor::rotate_about_in_view(center, p, cx.view_angle()); + } } Cursor::Crosshair } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index e53cea057..9f45a6630 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -469,9 +469,15 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); - // Just outside the bottom-right corner (7.07 pt away, past the corner widget's 5): a press - // there turns the selection about its centre. - let outside = Point::new(205.0, 205.0); + // On the corner's bounding-box handle, and on the corner widget itself, no turn: those keep + // their own gestures (the widget rounds the corner, the handle scales with the Selection tool). + for on_handle in [Point::new(205.0, 205.0), Point::new(200.0, 200.0)] { + let t = ShapeTool::new("rectangle"); + assert_ne!(t.cursor(&c, on_handle, Mods::default()), Cursor::rotate_about(centre, on_handle), "{on_handle:?}"); + } + // Just outside the bottom-right corner, past the handle's 5 pt square and within the 18 pt + // zone (8.49 pt away): a press there turns the selection about its centre. + let outside = Point::new(206.0, 206.0); let mut t = ShapeTool::new("rectangle"); assert_eq!(t.cursor(&c, outside, Mods::default()), Cursor::rotate_about(centre, outside)); // A turn begins with the press, as the Selection tool's does (its drags preview into it). diff --git a/docs/roadmap.md b/docs/roadmap.md index dd5504b0d..e29338b76 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -118,6 +118,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a9c2c2c7..3da7b29cf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -796,7 +796,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). 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 4/9] 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:?}"); +} From 6d3c0fef99cf3de9ed8047238969fd5f4f93bbcf Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:11:32 -0500 Subject: [PATCH 5/9] Shape tools: guides take the centre point's pointer too, and more regressions The Selection tool's cursor now checks the ruler guides before a selected object's centre point, as a press does. Tests: a ruler guide under the centre point keeps the pointer and drags; on a box too thin for its centre to clear the handles the handles take both; a shape-tool move from the centre snaps and shows the hollow arrow; and Alt pressed mid-drag copies the shape through the engine. --- crates/engine/src/tests/shapetools.rs | 14 ++++++++ crates/tools/src/select.rs | 12 +++---- crates/tools/src/select/tests.rs | 51 +++++++++++++++++++++++++++ crates/tools/src/shape/tests.rs | 26 ++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 8f2b181e2..3ecca4bef 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -94,6 +94,20 @@ fn a_shape_tool_moves_the_shape_from_its_centre_point() { assert_eq!(bounds(&s, copy), at, "back where it was"); } +/// Alt pressed once the drag has begun turns the move into a copy, as the Selection tool's. +#[test] +fn alt_pressed_mid_drag_copies_the_shape() { + let mut s = new_doc(); + let id = drawn_rect(&mut s); + let before = bounds(&s, id); + gesture(&mut s, NONE, &[(PointerKind::Down, 150.0, 140.0), (PointerKind::Drag, 170.0, 140.0)]); + gesture(&mut s, Mods { alt: true, ..NONE }, &[(PointerKind::Drag, 200.0, 140.0), (PointerKind::Up, 200.0, 140.0)]); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(|c| c.len()).unwrap_or_default(), 2, "a copy"); + assert_eq!(bounds(&s, id), before, "the original stays"); + let copy = *s.doc().unwrap().selection.objects.first().expect("the copy is selected"); + assert_eq!(bounds(&s, copy), before + Vec2::new(50.0, 0.0), "the copy moved by the whole drag"); +} + /// A press in the rotate zone just outside a corner of the shape's box turns it about that box's /// centre, as the Selection tool's does; a press on the corner itself still rounds it. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index eee24b4e9..8fc737937 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1128,15 +1128,15 @@ 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; } + // A selected object's centre point (Show Center): a press there moves it, where the art + // may not reach. The box's handles and the ruler guides take a press before it, so they + // take the pointer too (on a thin box the centre lies within a handle's reach). + if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + return Cursor::Move; + } // View → Snap to Point: over an anchor a press would grab the object by, the arrow turns // hollow. if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 7268d0678..c37a35455 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2199,3 +2199,54 @@ fn a_shift_press_on_the_centre_point_moves_or_deselects() { vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))] ); } + +/// On a box too thin for its centre point to clear the handles, the handles take the pointer and +/// the press, as they take the press itself: the centre is within their 5 pt reach. +#[test] +fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let shape = LiveShape::Rectangle { + w: 100.0, + h: 8.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + 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), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 104.0); + assert!(hit_center(&c, centre, c.point_tol()).is_some(), "the centre point is there"); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::ResizeV, "the top and bottom handles take it"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)), vec![Action::Begin("Scale".into())]); +} + +/// A ruler guide under a selected object's centre point keeps the pointer and the press, as it +/// keeps them over the art. +#[test] +fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { + let (mut d, id) = doc_with_live_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let centre = Point::new(150.0, 150.0); + assert!(hit_center(&c, centre, c.point_tol()).is_some()); + let mut t = SelectionTool::default(); + let cursor = t.cursor(&c, centre, Mods::default()); + assert_ne!(cursor, Cursor::Move, "the guide's cursor, not the move arrow"); + let down = t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)); + let drag = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "guide.move")), "the guide drags: {down:?} {drag:?}"); +} diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 60c3058e6..85a2d52fc 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -550,3 +550,29 @@ fn a_live_corner_widget_wins_over_another_shapes_centre_point() { let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); } + +/// A move of the selection from the centre point snaps like the Selection tool's, and the arrow +/// turns hollow while it does. +#[test] +fn a_move_from_the_centre_point_snaps_and_shows_the_hollow_arrow() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, s) = unfilled_live_rect(); + // Another rectangle whose left edge is at x = 300: the moved selection's right edge lands 4 pt + // short of it, within the Smart Guides' reach. + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 100.0, 350.0, 180.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 246.0, 150.0)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["matrix"][4] == json!(100.0)), "snapped to the edge: {a:?}"); + assert_eq!(t.cursor(&c, Point::new(246.0, 150.0), Mods::default()), Cursor::ArrowHollow); + // Away from the neighbour, the move is the pointer's own and the arrow is plain. + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 150.0)); + assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["matrix"][4] == json!(10.0)), "{a:?}"); + assert_eq!(t.cursor(&c, Point::new(160.0, 150.0), Mods::default()), Cursor::Arrow); +} From dd160dc76833b6daff51e346c070e4c08be02d3b Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:14:41 -0500 Subject: [PATCH 6/9] Selection: Cmd/Ctrl over a centre point selects behind, so the arrow stays plain The press with Cmd/Ctrl held selects the object behind rather than taking the centre point; the pointer now agrees. --- crates/tools/src/select.rs | 5 +++-- crates/tools/src/select/tests.rs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8fc737937..61d215069 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1133,8 +1133,9 @@ impl Tool for SelectionTool { } // A selected object's centre point (Show Center): a press there moves it, where the art // may not reach. The box's handles and the ruler guides take a press before it, so they - // take the pointer too (on a thin box the centre lies within a handle's reach). - if near_selected && hit_center(cx, p, cx.point_tol()).is_some() { + // take the pointer too (on a thin box the centre lies within a handle's reach), and with + // Cmd/Ctrl held the press selects the object behind instead. + if near_selected && !m.cmd && hit_center(cx, p, cx.point_tol()).is_some() { return Cursor::Move; } // View → Snap to Point: over an anchor a press would grab the object by, the arrow turns diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index c37a35455..eb926ffbc 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2153,6 +2153,8 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { let centre = Point::new(150.0, 150.0); let mut t = SelectionTool::default(); assert_eq!(t.cursor(&c, centre, Mods::default()), Cursor::Move); + // Cmd/Ctrl selects the object behind, not the centre point, so the pointer says neither. + assert_ne!(t.cursor(&c, centre, Mods { cmd: true, ..Mods::default() }), Cursor::Move); assert!(t.pointer(&c, &ev(PointerKind::Down, centre.x, centre.y)).is_empty()); let a = t.pointer(&c, &ev(PointerKind::Drag, 170.0, 160.0)); assert_eq!(a.first(), Some(&Action::Begin("Move".into())), "{a:?}"); From 10724077bcbcf4e622eb5d11108d891f07eefea1 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:44 -0500 Subject: [PATCH 7/9] docs: the shape-tool status lines name no other branch's change The roadmap's status line and revision history now record the shape tools' centre-point move and corner rotate (#5477), and the parity status line no longer names the Transform tab-order fix, which is its own pull request. --- docs/roadmap.md | 3 ++- docs/ui-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e29338b76..d9ac18528 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -179,6 +179,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. | | 2026-11-03 | minor | M7: the OpenType panel offers the selected font's available Stylistic Sets with custom or numbered names; estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1062d89fd..4efbee4aa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; Transform fields: one Tab per field; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence From 1c271c776ca640df3d42c59cacbfaa7632705267 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:23:18 -0500 Subject: [PATCH 8/9] Shape tools: review follow-ups from CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 45° move assertion compares dx and dy with a small tolerance instead of exact equality (the matrix's floats are rounded), keeping the non-zero check and the message. - The engine test helpers say what they do, so the docstring coverage of the functions this pull request touches is complete. --- crates/engine/src/tests/shapetools.rs | 7 +++++++ crates/tools/src/select/tests.rs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs index 3ecca4bef..3c7d34a4f 100644 --- a/crates/engine/src/tests/shapetools.rs +++ b/crates/engine/src/tests/shapetools.rs @@ -9,22 +9,26 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; +/// A new 400 × 400 pt document. fn new_doc() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); s } +/// The view the gestures run in: page coordinates, with Smart Guides off so nothing snaps. fn view() -> ViewInfo { ViewInfo { smart_guides: false, ..ViewInfo::default() } } +/// Feed the pointer events `(kind, x, y)`, all with `mods` held, to the active tool. fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { for (k, x, y) in events { s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), view()).unwrap(); } } +/// No modifier held. const NONE: Mods = Mods { shift: false, alt: false, cmd: false, ctrl: false, space: false }; /// A 100 × 80 pt rectangle drawn from (100, 100) to (200, 180) with the Rectangle tool, still @@ -35,14 +39,17 @@ fn drawn_rect(s: &mut Session) -> NodeId { *s.doc().unwrap().selection.objects.first().expect("the drawn rectangle is selected") } +/// The geometric bounds of `id`. fn bounds(s: &Session, id: NodeId) -> Rect { s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds") } +/// The angle of `id`'s bounding box, in degrees counter-clockwise: 0 for art square to the page. fn angle(s: &Session, id: NodeId) -> f64 { s.doc().unwrap().doc.oriented_bounds(&[id], false).map_or(0.0, |b| b.angle) } +/// How many undo steps the active document's history holds. fn undo_steps(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index eb926ffbc..94fb67786 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2191,7 +2191,8 @@ fn a_shift_press_on_the_centre_point_moves_or_deselects() { let Some(Action::Preview(cmd, v)) = a.last().filter(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")) else { panic!("{a:?}") }; assert_eq!(cmd, "object.transform"); let [_, _, _, _, dx, dy] = [0, 1, 2, 3, 4, 5].map(|i| v["matrix"][i].as_f64().unwrap_or_default()); - assert!(dx != 0.0 && dx == dy, "held to 45°: {dx} {dy}"); + // A 45° move, within the rounding of the matrix's own floats. + assert!(dx != 0.0 && (dx - dy).abs() < 1e-9, "held to 45°: {dx} {dy}"); assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 160.0).with_mods(shift)), vec![Action::Commit]); // A Shift-click there takes the object out of the selection on release. let mut t = SelectionTool::default(); From d572f3ce711a0c80947aad0e529b075d119b5997 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:38:22 -0500 Subject: [PATCH 9/9] Shape tools: the tool methods this pull request changes say what they do The `Tool` implementation's `busy`, `pointer`, `key`, `claims_key`, `overlays` and `cursor` in shape.rs, and `pointer` and `cursor` in select.rs, are the ones this pull request edits; each now has a doc comment, so the docstring coverage of the functions the diff touches is complete. --- crates/tools/src/select.rs | 4 ++++ crates/tools/src/shape.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 61d215069..0f879cb55 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -804,6 +804,8 @@ impl Tool for SelectionTool { } } + /// A press takes the selection's widgets, a ruler guide, a selected object's centre point or + /// the art; the drags then move, scale, rotate, marquee or drag what it took. fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.note_loaded_at_press(ev.kind); let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle); @@ -1061,6 +1063,8 @@ impl Tool for SelectionTool { o } + /// The pointer for the drag in progress, else the one for what a press would take: a widget, + /// a ruler guide, the box's handles or rotate zone, a selected object's centre point or the art. fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { if let State::Mirror(d) = &self.state { return d.cursor(cx, p); diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index d513b1f5e..076525be4 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -391,9 +391,13 @@ impl Tool for ShapeTool { fn id(&self) -> &'static str { self.id } + /// A shape being drawn, a Live Shape widget dragged, or the selection moved or turned is a + /// gesture in progress. fn busy(&self) -> bool { self.start.is_some() || self.widget.is_some() || self.selection.is_some() } + /// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on + /// the selection's centre point or in the rotate zone just outside its box's corner. fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.widget_pointer(cx, ev) { return out; @@ -469,6 +473,8 @@ impl Tool for ShapeTool { _ => vec![], } } + /// ↑/↓ change a polygon's sides, a star's points or the rounded rectangle's radius while it is + /// drawn; Escape turns a widget or selection drag back. fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { // A drag of the selection: Escape turns it back, as with the Selection tool. if self.selection.is_some() { @@ -540,6 +546,8 @@ impl Tool for ShapeTool { }, } } + /// ↑/↓ step a corner's kind while its widget is dragged, and Escape ends a drag of the + /// selection. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { match (&self.widget, &self.selection) { (Some(WidgetDrag::Corner(_)), _) => matches!(key, ToolKey::Up | ToolKey::Down), @@ -547,6 +555,8 @@ impl Tool for ShapeTool { _ => false, } } + /// The widget being dragged, the Smart Guides of the shape being drawn or the selection being + /// moved, and its size, angle or offset label. fn overlays(&self, cx: &ToolContext) -> Vec { match &self.widget { Some(WidgetDrag::Corner(c)) => return c.overlays(cx), @@ -588,6 +598,9 @@ impl Tool for ShapeTool { } o } + /// The Selection tool's pointer over a Live Shape widget, the selection's centre point or the + /// rotate zone just outside its box's corner; the crosshair everywhere else, where a press + /// draws. fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none();