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/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/crates/engine/src/tests/shapetools.rs b/crates/engine/src/tests/shapetools.rs new file mode 100644 index 000000000..3c7d34a4f --- /dev/null +++ b/crates/engine/src/tests/shapetools.rs @@ -0,0 +1,169 @@ +//! 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::*; + +/// 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 +/// 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") +} + +/// 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() +} + +/// 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, 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: &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(); + 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"); +} + +/// 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] +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:?}"); + // 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(); + 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 0ac3e610a..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,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), 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.", @@ -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..0f879cb55 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)) @@ -514,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 @@ -779,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); @@ -822,6 +849,19 @@ 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: 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 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). if m.cmd && cx.select_behind @@ -1023,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); @@ -1093,6 +1135,13 @@ impl Tool for SelectionTool { 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), 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 // 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 714975bb0..94fb67786 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2139,3 +2139,117 @@ 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); + // 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:?}"); + 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"); +} + +/// 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()); + // 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(); + 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}))] + ); +} + +/// 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.rs b/crates/tools/src/shape.rs index f48625a6a..076525be4 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::{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, 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 › @@ -38,6 +42,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 +73,15 @@ 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 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 @@ -95,6 +118,10 @@ impl ShapeTool { line_angle: 0.0, fill_line: false, widget: None, + selection: None, + guides: vec![], + measure: None, + snapped: false, } } @@ -112,6 +139,103 @@ 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` 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 { + rotate_zone(cx, &self.box_of(cx)?, p) + } + + /// 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 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}))); + 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; + self.snapped = false; + 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; + self.snapped = false; + 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()?; @@ -267,17 +391,24 @@ 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.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; } + 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 +420,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; @@ -339,7 +473,13 @@ 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() { + 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,12 +539,24 @@ 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, + }, } } + /// ↑/↓ 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 { - 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, + } } + /// 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), @@ -412,6 +564,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" { @@ -442,16 +598,43 @@ impl Tool for ShapeTool { } o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + /// 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(); 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 => {} } + // 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 => {} + } + // 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 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 } fn options(&self) -> Value { json!({ diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index f51cf117f..85a2d52fc 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -423,3 +423,156 @@ 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}"); + } + // 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"); + 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:?}"); + // 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). + 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:?}"); +} + +/// 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:?}"); +} + +/// 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); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index dd5504b0d..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 @@ -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 | @@ -178,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/target-app-parity.md b/docs/target-app-parity.md index 8761aad38..3da7b29cf 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 @@ -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). @@ -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..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; 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; 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 (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 |