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] 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 |