diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 202bfc10f..7bfaa29ec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2348,12 +2348,19 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve } } for (si, ai, a) in path.anchors() { - let sel = match partial { - Some(set) => set.contains(&(si, ai)), - None => !direct || c.id == *id, + // An object selected whole has all its anchors selected, and so do the paths inside a + // group, compound path or clip group selected whole. Direction handles show only on a + // path that is selected itself: those are the handles that `handle_anchors` in + // `vectorcraft-tools` lets the tools drag. + let (sel, handles_shown) = match partial { + Some(set) => { + let sel = set.contains(&(si, ai)); + (sel, sel) + } + None => (true, direct && c.id == *id), }; let sp = xf.to_screen(a.p); - if !curvature && sel && (direct || partial.is_some()) { + if !curvature && handles_shown { with_handles += 1; let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true)); handles.extend( @@ -4253,6 +4260,79 @@ mod tests { assert_eq!(app.session.doc().unwrap().doc, before); } + /// A group, a compound path or a clip group selected whole has every anchor selected, and Direct + /// Selection, the Pen and the anchor point tools draw its anchors filled, as they draw the anchors + /// of a path selected whole. They do the same after Group Selection clicks select a path and then its group. + /// Direction handles still show only on a path selected itself. + #[test] + fn the_anchors_of_a_container_selected_whole_draw_selected() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let rect = |app: &mut VectorcraftApp, x: i32, y: i32| { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 60})).unwrap()["id"].clone() + }; + let in_group = rect(&mut app, 20, 20); + let ellipse = app.run("shape.ellipse", json!({"x": 100, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [in_group, ellipse]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].clone(); + let (c1, c2) = (rect(&mut app, 200, 20), rect(&mut app, 220, 40)); + app.run("select.set", json!({"ids": [c1, c2]})).unwrap(); + let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].clone(); + let hidden = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 200, "height": 100})).unwrap()["id"].clone(); + let square = rect(&mut app, 60, 170); + app.run("select.set", json!({"ids": [hidden, square]})).unwrap(); + let clip = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone(); + let lone = app.run("shape.ellipse", json!({"x": 300, "y": 150, "width": 60, "height": 60})).unwrap()["id"].clone(); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(lone.as_u64().unwrap()))); + let ctx = egui::Context::default(); + // Filled and hollow anchor squares, and direction handle lines, drawn in the layer color. + let looks = |app: &mut VectorcraftApp| { + let painted = shapes(app, &ctx); + let small = |r: &egui::epaint::RectShape| r.rect.width() < 12.0; + let filled = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == color)).count(); + let hollow = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == Color32::WHITE)).count(); + let handles = painted.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count(); + (filled, hollow, handles) + }; + let mut wrong = vec![]; + for tool in ["directSelection", "pen", "addAnchor", "deleteAnchor", "anchorPoint"] { + app.select_tool(tool); + // What is selected, and what it shows: the lone ellipse selected whole is the reference + // (four anchors with two handles each, and its center point, filled too). + for (what, ids, want) in [ + ("a path", json!([lone]), (5, 0, 8)), + ("a group", json!([group]), (8, 0, 0)), + ("a compound path", json!([compound]), (8, 0, 0)), + ("a clip group", json!([clip]), (4, 0, 0)), + ] { + app.run("select.set", json!({ "ids": ids })).unwrap(); + let got = looks(&mut app); + if got != want { + wrong.push(format!("{tool}, {what}: (filled, hollow, handles) {got:?}, want {want:?}")); + } + } + } + app.run("select.none", json!({})).unwrap(); + app.select_tool("groupSelection"); + let view = app.view_info(); + for _ in 0..2 { + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 20.0, 50.0), view).unwrap(); + } + } + let id = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap()); + assert_eq!(app.session.active().unwrap().selection.objects, vec![id(&in_group), id(&group)]); + app.select_tool("directSelection"); + // The rectangle's anchors and center point, then the group's eight anchors, four of them over the rectangle's. + let got = looks(&mut app); + if got != (13, 0, 0) { + wrong.push(format!("a Group Selection click on a path, then on its group: {got:?}, want (13, 0, 0)")); + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); + } + /// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one /// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas /// renders for the screen again. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9d8a47fd1..06aa47310 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -527,6 +527,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built. - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. + With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies). `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. - **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path. With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).