Merge pull request #5839 from mockydevs/feat/handles-after-drawing-5477

Shape tools: a shape just drawn shows its bounding box and Live Shape widgets, and its corner and pie widgets drag with the shape tool (#5477)
This commit is contained in:
BFlat
2026-11-06 02:02:55 +07:00
committed by GitHub
5 changed files with 196 additions and 14 deletions
+107 -4
View File
@@ -6,11 +6,19 @@
//! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt
//! keeps the star's shoulders straight (#812). The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
//!
//! 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).
use serde_json::{Value, json};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::corners::{self, CornerDrag};
use crate::guides::{DrawSnap, Leave, square};
use crate::pie::{self, PieDrag};
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 ›
@@ -18,6 +26,18 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
/// value stop there too.
pub const MAX_CORNER_RADIUS: f64 = 1296.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 {
matches!(id, "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star")
}
/// A drag on a Live Shape widget of the selection, taken by a shape tool.
enum WidgetDrag {
Corner(CornerDrag),
Pie(PieDrag),
}
pub struct ShapeTool {
id: &'static str,
start: Option<Point>,
@@ -37,6 +57,8 @@ pub struct ShapeTool {
pub line_angle: f64,
pub fill_line: bool,
snap: DrawSnap,
/// A widget of the selection is being dragged instead of a shape drawn.
widget: Option<WidgetDrag>,
}
/// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each
@@ -72,9 +94,41 @@ impl ShapeTool {
line_length: 100.0,
line_angle: 0.0,
fill_line: false,
widget: None,
}
}
/// A press on a corner or pie widget of the selection: the drag adjusts it.
fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool {
if !shows_shape_widgets(self.id) {
return false;
}
self.widget = CornerDrag::hit(cx, ev, false).map(WidgetDrag::Corner).or_else(|| PieDrag::hit(cx, ev).map(WidgetDrag::Pie));
self.widget.is_some()
}
/// Is `p` over a corner or pie widget of the selection that a press would drag?
fn over_widget(&self, cx: &ToolContext, p: Point) -> bool {
shows_shape_widgets(self.id) && (corners::over_widget(cx, p, false) || pie::over_widget(cx, p))
}
/// 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()?;
Some(match ev.kind {
PointerKind::Drag => match w {
WidgetDrag::Corner(c) => c.drag(cx, ev.pos),
WidgetDrag::Pie(d) => d.drag(cx, ev.pos),
},
PointerKind::Up => match self.widget.take() {
Some(WidgetDrag::Corner(c)) => c.finish(),
Some(WidgetDrag::Pie(d)) => d.finish(),
None => vec![],
},
_ => vec![],
})
}
/// Ctrl/Cmd in a star drag from `start` to `p`, where the last drag showed a star with the inner
/// radius `shown`: once the star has begun, the inner radius stays as the star showed it when
/// the key went down, so the ratio follows the outer radius (and stays so once the key is let
@@ -214,15 +268,27 @@ impl Tool for ShapeTool {
self.id
}
fn busy(&self) -> bool {
self.start.is_some()
self.start.is_some() || self.widget.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if let Some(out) = self.widget_pointer(cx, ev) {
return out;
}
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
// Over a widget a press would drag, no Smart Guide for a new shape's start.
if self.over_widget(cx, ev.pos) {
self.snap.clear();
} else {
self.snap.hover(cx, ev.pos, &[], None);
}
vec![]
}
PointerKind::Down => {
if self.press_widget(cx, ev) {
self.snap.clear();
return vec![];
}
let p = self.snap.press(cx, ev.pos, &[], None);
self.start = Some(p);
self.last = ev.pos;
@@ -274,6 +340,19 @@ impl Tool for ShapeTool {
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// 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 {
return match (key, w) {
(ToolKey::Up | ToolKey::Down, WidgetDrag::Corner(c)) => c.step_kind(key == ToolKey::Up),
(ToolKey::Escape, _) => match self.widget.take() {
Some(WidgetDrag::Corner(c)) if c.adjusting() => vec![Action::Cancel],
Some(WidgetDrag::Pie(d)) if d.adjusting() => vec![Action::Cancel],
_ => vec![],
},
_ => vec![],
};
}
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("polygon", ToolKey::Up) => {
@@ -315,7 +394,23 @@ impl Tool for ShapeTool {
vec![]
}
}
/// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool).
fn transforming(&self) -> bool {
match &self.widget {
Some(WidgetDrag::Corner(c)) => c.adjusting(),
Some(WidgetDrag::Pie(d)) => d.adjusting(),
None => false,
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(self.widget, Some(WidgetDrag::Corner(_))) && matches!(key, ToolKey::Up | ToolKey::Down)
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match &self.widget {
Some(WidgetDrag::Corner(c)) => return c.overlays(cx),
Some(WidgetDrag::Pie(d)) => return d.overlays(cx),
None => {}
}
let mut o = self.snap.guides().to_vec();
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));
@@ -347,8 +442,16 @@ impl Tool for ShapeTool {
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
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,
}
}
fn options(&self) -> Value {
json!({
+70
View File
@@ -353,3 +353,73 @@ fn line_segment_options_and_fill_line() {
);
assert_eq!(t.command(a, b, Mods::default()).1["fill"], json!(true));
}
/// #5477: with a shape tool, a drag on a selected live rectangle's corner widget rounds its
/// corners (one undo step) and a drag on a live ellipse's pie widget sets its pie angle, as with
/// the Selection tool; the pointer over a widget is the Selection tool's. A press anywhere else, or
/// with Line Segment, draws.
#[test]
fn a_shape_tool_drags_the_selected_shapes_widgets() {
use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind};
let live = |shape: LiveShape| {
let mut d = vectorcraft_doc::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_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(Box::new(shape));
}
d.insert(Some(l), 0, n).unwrap();
let mut s = Selection::default();
s.add(id);
(d, s)
};
let xf = Affine::translate((100.0, 100.0));
let (d, s) = live(LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), radii_relative: false, xf });
let p = paint();
let c = cx(&d, &s, &p);
let at = Point::new(110.0, 110.0);
for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] {
let mut t = ShapeTool::new(id);
assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::CornerRadius, "{id}");
// Hovering a widget shows no Smart Guide for a new shape.
t.pointer(&c, &PointerEvent::new(PointerKind::Move, at.x, at.y));
assert!(t.overlays(&c).is_empty(), "{id}");
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty(), "{id}");
assert!(t.busy(), "{id}");
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0));
assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{id}: {a:?}");
assert!(
matches!(a.last(), Some(Action::Preview(cmd, v)) if cmd == "object.setLiveShape" && v["radius"].as_f64().is_some_and(|r| r > 0.0)),
"{id}: {a:?}"
);
assert!(t.transforming(), "{id}");
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 130.0)), vec![Action::Commit], "{id}");
assert!(!t.busy() && !t.transforming(), "{id}");
// Escape cancels a widget drag.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0));
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}");
assert!(!t.busy(), "{id}");
// Away from the widgets the tool draws.
assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Crosshair, "{id}");
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 350.0, 340.0));
assert!(matches!(a.last(), Some(Action::Preview(cmd, _)) if cmd.starts_with("shape.")), "{id}: {a:?}");
}
// Line Segment draws from a widget, as it always did.
let mut t = ShapeTool::new("lineSegment");
assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Crosshair);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0));
assert!(matches!(a.last(), Some(Action::Preview(cmd, _)) if cmd == "shape.line"), "{a:?}");
// A whole live ellipse's pie widgets sit 12 px right of (300, 150).
let (d, s) = live(LiveShape::Ellipse { w: 200.0, h: 100.0, pie: (0.0, 360.0), xf });
let c = cx(&d, &s, &p);
let mut t = ShapeTool::new("ellipse");
assert_eq!(t.cursor(&c, Point::new(312.0, 150.0), Mods::default()), Cursor::ArrowHollow);
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 312.0, 150.0)).is_empty());
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 200.0, 20.0));
assert_eq!(a.first(), Some(&Action::Begin("Live Shape".into())), "{a:?}");
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]);
}
+12 -6
View File
@@ -90,12 +90,17 @@ fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f
}
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
// hides while a drag moves or resizes the selection, so only the art is seen going with the
// pointer (#712).
// With Real-time Drawing off the art waits for the release of a move drag, and the box moves
// with the pointer instead.
let moved = app.session.deferred_move();
let show_box = app.session.tool_id() == "selection"
let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id());
// A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets.
let drawing = shape_tool && app.session.tool_busy() && !app.session.tool_transforming();
let show_box = (app.session.tool_id() == "selection" || (shape_tool && !app.session.tool_busy()))
&& (!app.session.tool_transforming() || moved.is_some())
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
@@ -250,8 +255,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct));
}
}
// Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path).
if matches!(tool, "selection" | "directSelection")
// Live Corners widgets (Selection and the shape tools on a live rectangle or polygon, Direct
// Selection on any path).
if (matches!(tool, "selection" | "directSelection") || (shape_tool && !drawing))
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing(
&st.doc,
&st.selection,
@@ -287,7 +293,7 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
}
// Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined
// by a line to the outline's point at its angle. They stay while one is dragged.
if tool == "selection" && app.ui.view.bounding_box {
if (tool == "selection" || (shape_tool && !drawing)) && app.ui.view.bounding_box {
for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) {
let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id)));
p.line_segment([on, c], Stroke::new(1.0, color));
+4 -3
View File
@@ -1466,9 +1466,10 @@ fn a_huge_selection_of_type_images_symbols_or_meshes_shows_its_bounds() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.ui.task_bar = false;
// The Rectangle tool draws nothing of its own (no bounding box, no type on a path
// brackets): only the selection's overlay changes the frame.
app.select_tool("rectangle");
// The Line Segment tool draws nothing of its own (no bounding box, no type on a path
// brackets; the box-drawing shape tools show the bounding box, #5477): only the
// selection's overlay changes the frame.
app.select_tool("lineSegment");
match kind {
"point type" => {
app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type"})).unwrap();
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; 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-05 · **Change:** minor (Shape tools: a shape just drawn shows its bounding box and Live Shape widgets, and its corner and pie widgets drag with the shape tool, #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
@@ -38,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | Shape tools: a shape just drawn keeps its bounding box and Live Shape widgets on show with the Rectangle, Rounded Rectangle, Ellipse, Polygon or Star tool, and its corner and pie widgets drag as with the Selection tool (#5477); scores and estimates unchanged |
| 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged |
| 2026-11-05 | minor | Appearance panel: Delete Selected Item (and Delete/Backspace, and Remove Item in the panel menu) on a selected Opacity row, the object's or a fill's or stroke's, sets it back to Default (100%, Normal) as one undo step instead of being dimmed (#5742); scores and estimates unchanged |
| 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). |
@@ -175,6 +176,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` |
| 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 |