Shape tools: the selection moves from its centre point and turns outside a corner

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.
This commit is contained in:
FractalEncrypt
2026-11-07 05:15:02 +07:00
committed by bflatastic
parent 9c879301b4
commit adbcb3a224
9 changed files with 336 additions and 27 deletions
+4 -1
View File
@@ -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."
+1 -11
View File
@@ -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<AnchorRef>)> {
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
+22
View File
@@ -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.
+31 -1
View File
@@ -469,10 +469,23 @@ pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> {
/// [`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<OrientedBox> {
/// The shape tools press it too (#5477).
pub(crate) fn shown_box(cx: &ToolContext) -> Option<OrientedBox> {
(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<Rect> {
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;
}
+30
View File
@@ -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");
}
+175 -11
View File
@@ -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<MoveSnap> },
/// Turning the selection about `center` from where the press was.
Rotate { center: Point, start: Point },
}
pub struct ShapeTool {
id: &'static str,
start: Option<Point>,
@@ -59,6 +77,12 @@ pub struct ShapeTool {
snap: DrawSnap,
/// A widget of the selection is being dragged instead of a shape drawn.
widget: Option<WidgetDrag>,
/// The selection is being moved or turned instead of a shape drawn.
selection: Option<SelectionDrag>,
/// The Smart Guides a move of the selection shows.
guides: Vec<Overlay>,
/// 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<vectorcraft_doc::OrientedBox> {
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<Point> {
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<Vec<Action>> {
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<Vec<Action>> {
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<Action> {
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<Vec<Action>> {
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<Action> {
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<Action> {
// 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<Overlay> {
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!({
+68
View File
@@ -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:?}");
}
+2 -1
View File
@@ -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. |
+3 -2
View File
@@ -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 |