collective

Browse / github.com/storytold/designcraft / patch

Direct Selection: handles around the selected anchors only, and no bounding box

Direct Selection: handles around the selected anchors only, and no bounding box Found while replicating the *InDesign Full Course Tutorial* (Learn Skills Daily), chapter 43, "Direct Selection". The lesson: - draws a rectangle and pulls one corner with the Direct Selection tool to make a triangle, then places a photo into it; - draws a circle (Shift), selects its bottom anchor and drags it and its handles to make an egg. With #1416 and #902 (on which this is stacked), DesignCraft does all of that. Two things still looked different from InDesign: - **Every handle showed.** Direct Selection drew both direction handles of every anchor on a selected path. On a circle, that wa

Status
open
Score
0.00
Adopters
0 other operators
Author
moppeeer (imported from pull request #1657, open)
Base
d4c1322d97bf0ea28cfbaf35a626f5dfc75428f0
Size
+609 −62 in 8 files
Review
pending
Shared
2026-10-28
Id
2adaeda38d434dd62c0b7673

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/anchors.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/object.rs crates/engine/src/tests.rs crates/tools/src/select.rs crates/tools/src/tests.rs crates/ui-egui/src/canvas.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -366,6 +366,7 @@ Newest first. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.+- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.@@ -386,6 +387,8 @@ Newest first. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.+- **2026-10-14** · The Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). No readiness percentage change.+- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs--- a/crates/engine/src/cmd/anchors.rs+++ b/crates/engine/src/cmd/anchors.rs@@ -289,7 +289,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {     let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?;     let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;     let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);-    s.edit(|d, _| {+    s.edit(|d, sel| {         path_editable(d, id, "path.addAnchor")?;         let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;         let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;@@ -303,6 +303,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {         let t = t.clamp(1e-4, 1.0 - 1e-4);         let ai = it.path.subpaths[si].insert_anchor(seg, t);         as_path(&mut it.shape);+        // Selected points after the new one move up an index, so they stay the same points.+        for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) {+            a.2 += 1;+        }         Ok(json!({"subpath": si, "anchor": ai}))     }) }@@ -318,15 +322,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {         }         sp.anchors.remove(ai);         // A subpath needs two points (three to stay closed).-        if sp.anchors.len() < 2 {+        let removed_subpath = sp.anchors.len() < 2;+        if removed_subpath {             it.path.subpaths.remove(si);         } else if sp.closed && sp.anchors.len() < 3 {             sp.closed = false;         }         as_path(&mut it.shape);+        // The deleted point leaves the selection; the selected points after it (or the subpaths+        // after a removed one) move down an index.+        sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath)));+        for a in sel.anchors.iter_mut().filter(|a| a.0 == id) {+            if removed_subpath && a.1 > si {+                a.1 -= 1;+            } else if !removed_subpath && a.1 == si && a.2 > ai {+                a.2 -= 1;+            }+        }         if it.path.subpaths.is_empty() {             d.remove_item(id)?;             sel.items.retain(|i| *i != id);+            sel.anchors.retain(|a| a.0 != id);             return Ok(json!({"deleted": true}));         }         Ok(json!({"deleted": false}))@@ -486,6 +502,33 @@ mod tests {         assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err());     } +    /// The Direct Selection's selected points stay the same points when the path changes under+    /// them: a point added before them, one of them deleted, the path reversed.+    #[test]+    fn selected_anchors_follow_added_deleted_and_reversed_points() {+        let mut s = Session::new();+        s.execute("file.new", &json!({})).unwrap();+        let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();+        let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p;+        s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap();+        let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::<Vec<_>>();+        let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect();+        // A point added on the first side, before both.+        let first = point(&s, (0, 0));+        let second = point(&s, (0, 1));+        let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0];+        s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap();+        assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::<Vec<_>>(), before, "after an added point");+        // One of them deleted: the other stays selected, the same point.+        let (si, ai) = picked(&s)[0];+        s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap();+        assert_eq!(picked(&s).len(), 1);+        assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point");+        // Reversed: still the same point.+        s.execute("path.reverse", &json!({"ids": [id]})).unwrap();+        assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing");+    }+     #[test]     fn add_convert_delete_anchor_points() {         let mut s = Session::new();diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -8,6 +8,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; +/// `anchors` of `selection.set`: the points that exist, once each.+fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> {+    let mut out: Vec<(ItemId, usize, usize)> = Vec::new();+    for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() {+        let n = |i: usize| v.get(i).and_then(Value::as_u64);+        let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue };+        let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue };+        let a = (ItemId(id), si, ai);+        if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) {+            out.push(a);+        }+    }+    out+}+ fn can_undo(s: &Session) -> std::result::Result<(), String> {     s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) }@@ -235,11 +250,17 @@ pub fn specs() -> Vec<CommandSpec> {             s.typing_format = None;             Ok(json!({"redone": e.label}))         }),-        cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {-            let ids = ids_param(p, "ids").unwrap_or_default();+        cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| {+            let mut ids = ids_param(p, "ids").unwrap_or_default();             let add = bool_or(p, "add", false);             let content = bool_or(p, "content", false);             let st = s.doc_mut()?;+            let anchors = anchors_param(&st.doc, p);+            for (id, _, _) in &anchors {+                if !ids.contains(id) {+                    ids.push(*id);+                }+            }             let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();             if add {                 for i in ids {@@ -247,8 +268,13 @@ pub fn specs() -> Vec<CommandSpec> {                         st.selection.items.push(i);                     }                 }+                for a in anchors {+                    if !st.selection.anchors.contains(&a) {+                        st.selection.anchors.push(a);+                    }+                }             } else {-                st.selection = Selection { items: ids, content, ..Default::default() };+                st.selection = Selection { items: ids, content, anchors, ..Default::default() };             }             st.selection.text = None;             st.selection.cells = None;@@ -262,6 +288,8 @@ pub fn specs() -> Vec<CommandSpec> {             st.selection.cells = None;             if let Some(i) = st.selection.items.iter().position(|x| *x == id) {                 st.selection.items.remove(i);+                // Its points go with it: arrow keys and Delete act on the selected points.+                st.selection.anchors.retain(|a| a.0 != id);                 // A key object needs another object to align to it.                 if st.selection.key == Some(id) || st.selection.items.len() < 2 {                     st.selection.key = None;diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs--- a/crates/engine/src/cmd/object.rs+++ b/crates/engine/src/cmd/object.rs@@ -680,7 +680,15 @@ pub fn specs() -> Vec<CommandSpec> {             path_reverse         ),         cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),-        cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),+        cmd!(+            "path.moveAnchors",+            "Move Anchors",+            [],+            None,+            "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)",+            has_doc,+            path_move_anchors+        ),         cmd!(             "object.align",             "Align",@@ -3204,29 +3212,36 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> { fn path_append(s: &mut Session, p: &Value) -> Result<Value> {     let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;     let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;-    let subpath = p.get("subpath").and_then(Value::as_u64);+    // An omitted subpath is the last one; one given must name a subpath (never falls back).+    let subpath = match p.get("subpath") {+        None | Some(Value::Null) => None,+        Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?),+    };     let at_start = match p.get("at") {         None => false,         Some(v) if v.as_str() == Some("end") => false,         Some(v) if v.as_str() == Some("start") => true,         Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")),     };-    s.edit(|d, _| {+    s.edit(|d, sel| {         path_editable(d, id, "path.appendAnchor")?;         let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;         let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();         let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;-        let sp = match subpath {-            Some(i) => {-                usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?-            }-            None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?,+        let si = match subpath {+            Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?,+            None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?,         };+        let sp = &mut it.path.subpaths[si];         if sp.closed {             return Err(bad("path.appendAnchor", "the subpath is closed"));         }         if at_start {             sp.anchors.insert(0, a.transform(inv));+            // The selected points of this sub-path move up one place with the others.+            for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) {+                a.2 += 1;+            }         } else {             sp.anchors.push(a.transform(inv));         }@@ -3305,7 +3320,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {     {         path_editable(doc, *id, "path.reverse")?;     }-    s.edit(|d, _| {+    s.edit(|d, sel| {         let mut reversed = 0;         for id in &editable {             let Some(it) = d.item_mut(*id) else { continue };@@ -3316,6 +3331,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {                 if only.is_some_and(|o| o != i) {                     continue;                 }+                // Selected points keep pointing at the same points, now counted from the other end.+                let n = sp.anchors.len();+                for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) {+                    a.2 = n - 1 - a.2;+                }                 sp.anchors.reverse();                 for a in &mut sp.anchors {                     std::mem::swap(&mut a.h_in, &mut a.h_out);@@ -3349,38 +3369,65 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> { }  fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {-    let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;-    let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();+    // The anchors to move, by path: `points` (several paths) or `id` with `anchors`.+    let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new();+    // A `points` that isn't a list is an error, never the `id` form (which could move another path).+    let points = match p.get("points") {+        None | Some(Value::Null) => None,+        Some(Value::Array(a)) => Some(a),+        Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index]")),+    };+    if let Some(points) = points {+        for v in points {+            let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());+            let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {+                return Err(bad("path.moveAnchors", "each point is [id, subpath, index]"));+            };+            match targets.iter_mut().find(|(i, _)| i.0 == id) {+                Some((_, which)) => which.push((si, ai)),+                None => targets.push((ItemId(id), vec![(si, ai)])),+            }+        }+    } else {+        let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;+        let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();+        targets.push((id, which));+    }     let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));     let handle = str_param(p, "handle").map(str::to_string);     s.edit(|d, _| {-        path_editable(d, id, "path.moveAnchors")?;-        let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;-        let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();-        // Spread-space delta → inner-space delta (linear part only).-        let inv = m.inverse();-        let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);-        let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;-        for (si, ai) in &which {-            let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };-            match handle.as_deref() {-                Some("in") => {-                    a.h_in += dv;-                    if a.kind == designcraft_geom::AnchorKind::Smooth {-                        a.h_out = a.p - (a.h_in - a.p);+        for (id, _) in &targets {+            path_editable(d, *id, "path.moveAnchors")?;+        }+        for (id, which) in &targets {+            let id = *id;+            let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;+            let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();+            // Spread-space delta → inner-space delta (linear part only).+            let inv = m.inverse();+            let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);+            let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;+            for (si, ai) in which {+                let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };+                match handle.as_deref() {+                    Some("in") => {+                        a.h_in += dv;+                        if a.kind == designcraft_geom::AnchorKind::Smooth {+                            a.h_out = a.p - (a.h_in - a.p);+                        }                     }-                }-                Some("out") => {-                    a.h_out += dv;-                    if a.kind == designcraft_geom::AnchorKind::Smooth {-                        a.h_in = a.p - (a.h_out - a.p);+                    Some("out") => {+                        a.h_out += dv;+                        if a.kind == designcraft_geom::AnchorKind::Smooth {+                            a.h_in = a.p - (a.h_out - a.p);+                        }                     }+                    _ => a.translate(dv),                 }-                _ => a.translate(dv),             }-        }-        if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {-            it.shape = Shape::Path;+            if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {+                it.shape = Shape::Path;+            }         }         ok()     })diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -1652,6 +1652,153 @@ fn a_line_fits_when_its_baseline_does() {     assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } +/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles+/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor+/// catch a press, as in InDesign.+#[test]+fn direct_selection_drags_only_the_handles_around_the_selected_anchor() {+    use designcraft_tools::{PointerEvent, PointerKind};+    let mut s = session();+    let v = ViewInfo::at_zoom(1.0);+    let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();+    s.execute("selection.set", &json!({"ids": [id]})).unwrap();+    s.set_tool("directSelection");+    // Each anchor and its two handles, in the spread.+    let anchors = |s: &Session| {+        let st = s.doc().unwrap();+        let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();+        it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::<Vec<_>>()+    };+    let before = anchors(&s);+    let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap();+    let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y));+    let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| {+        s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();+        s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap();+        s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();+        s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();+    };+    // Select the bottom anchor.+    let b = before[bottom].0;+    press(&mut s, b, b);+    assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]);+    // Its handle drags: the handle moves.+    let h = before[bottom].2;+    press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0));+    let after = anchors(&s);+    assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]);+    // The top anchor's handle isn't shown, so a press on it doesn't take it.+    let t = before[top].2;+    press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0));+    let after2 = anchors(&s);+    assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]);+}++/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a+/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of+/// parallelograms).+#[test]+fn direct_selection_moves_the_points_of_several_frames_at_once() {+    use designcraft_tools::{Mods, PointerEvent, PointerKind};+    let mut s = session();+    let v = ViewInfo::at_zoom(1.0);+    let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();+    let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();+    s.execute("selection.set", &json!({"ids": []})).unwrap();+    s.set_tool("directSelection");+    s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap();+    let sel = s.doc().unwrap().selection.clone();+    assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors);+    assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b)));+    let shift = Mods { shift: true, ..Default::default() };+    s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap();+    let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> {+        let st = s.doc().unwrap();+        let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();+        it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect()+    };+    // Both frames: top points 36 pt to the right, bottom points where they were.+    assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);+    assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);+    assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);+}++#[test]+fn selected_points_follow_their_path() {+    let mut s = session();+    let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();+    let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();+    s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap();+    // Shift-clicking a frame off the selection drops its points too.+    s.execute("selection.toggle", &json!({"id": a})).unwrap();+    let id = |n| designcraft_doc::ItemId(n);+    assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]);+    // A point added before the first one: the selected point keeps pointing at the same anchor.+    let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap();+    s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap();+    s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap();+    let st = s.doc().unwrap();+    let (_, si, ai) = st.selection.anchors[0];+    let it = st.doc.item(id(l)).unwrap();+    assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0));+    // `points` that isn't a list is refused, even with an `id` beside it.+    for bad in [json!("x"), json!({"0": 1}), json!(5)] {+        assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}");+    }+    for bad in [json!(3), json!(-1), json!("0")] {+        assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}");+    }+    assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended");+}++#[test]+fn direct_selection_pulls_one_corner_of_an_unselected_frame() {+    // Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside,+    // then press on a frame's corner and pull it: only that corner moves (a triangle, nearly).+    use designcraft_tools::{PointerEvent, PointerKind, SnapView};+    let mut s = Session::new();+    s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();+    let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();+    s.execute("edit.deselectAll", &json!({})).unwrap();+    let corners = |s: &Session| {+        let d = &s.doc().unwrap().doc;+        let it = d.item(designcraft_doc::ItemId(id)).unwrap();+        it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::<Vec<_>>()+    };+    let before = corners(&s);+    s.set_tool("directSelection");+    let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };+    let layout = s.layout();+    let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y));+    let (a, b) = (at(455.0, 219.0), at(455.0, 150.0));+    s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();+    let after = corners(&s);+    let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect();+    assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}");+    assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}");+    assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected");+    // One undo step puts the corner back.+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(corners(&s), before);+    // Away from its corners, the White Arrow still drags the whole unselected frame.+    s.execute("edit.deselectAll", &json!({})).unwrap();+    let (c, d) = (at(300.0, 219.0), at(300.0, 269.0));+    s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap();+    s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap();+    assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s));+}+ /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test]diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs--- a/crates/tools/src/select.rs+++ b/crates/tools/src/select.rs@@ -92,6 +92,8 @@ enum Drag {         /// None when that point could not be read. The live document during a drag is the         /// previous preview, so the snap must not read the anchor from it again.         at: Option<(SpreadRef, Point)>,+        /// The other selected anchor points, which move with it: (item, subpath, anchor).+        others: Vec<(u64, usize, usize)>,     }, } @@ -144,21 +146,42 @@ pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Optio     anchor_at_in(cx, &cx.selection.items, p) } -/// [`anchor_at`] over the given items.+/// Whether the Direct Selection tool shows (and lets you drag) a direction handle of anchor `ai`+/// of a sub-path of `n` anchors, as InDesign: both handles of a selected anchor, and on its+/// neighbours the handle that shapes the segment to it (`out`: the outgoing handle). With no+/// anchor selected no handle shows.+pub fn handle_shown(n: usize, closed: bool, ai: usize, out: bool, chosen: impl Fn(usize) -> bool) -> bool {+    if chosen(ai) {+        return true;+    }+    let neighbour = if out {+        if ai + 1 < n { Some(ai + 1) } else { closed.then_some(0) }+    } else if ai > 0 {+        Some(ai - 1)+    } else {+        closed.then(|| n.saturating_sub(1))+    };+    neighbour.is_some_and(chosen)+}++/// [`anchor_at`] over the given items. Of the direction handles, only those [`handle_shown`]+/// shows are found. pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {     let tol = cx.tol(5.0);     for id in ids {         let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue };         let m = xf * it.xf;         for (si, sp) in it.path.subpaths.iter().enumerate() {+            let chosen = |a: usize| cx.selection.anchors.contains(&(*id, si, a));             for (ai, a) in sp.anchors.iter().enumerate() {                 if ((m * a.p) - p).hypot() <= tol {                     return Some((id.0, si, ai, None));                 }-                if a.has_in() && ((m * a.h_in) - p).hypot() <= tol {+                let shown = |out: bool| handle_shown(sp.anchors.len(), sp.closed, ai, out, chosen);+                if a.has_in() && shown(false) && ((m * a.h_in) - p).hypot() <= tol {                     return Some((id.0, si, ai, Some("in")));                 }-                if a.has_out() && ((m * a.h_out) - p).hypot() <= tol {+                if a.has_out() && shown(true) && ((m * a.h_out) - p).hypot() <= tol {                     return Some((id.0, si, ai, Some("out")));                 }             }@@ -181,6 +204,37 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {     (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included.+fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> {+    fn walk(cx: &ToolContext, items: &[std::sync::Arc<Item>], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) {+        for it in items {+            let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);+            if locked || it.hidden {+                continue;+            }+            if let Content::Group { items } = &it.content {+                walk(cx, items, r, out);+                continue;+            }+            let Some(xf) = cx.item_canvas_xf(it.id) else { continue };+            let m = xf * it.xf;+            for (si, ai, a) in it.path.anchors() {+                let q = m * a.p;+                if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 {+                    out.push((it.id, si, ai));+                }+            }+        }+    }+    let mut out = Vec::new();+    for slot in &cx.layout.slots {+        if let Some(sp) = cx.doc.spread(slot.spread) {+            walk(cx, &sp.items, r, &mut out);+        }+    }+    out+}+ /// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select` /// takes them: none while guides are hidden or locked. fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {@@ -217,6 +271,29 @@ fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec<Value> {     out } +/// `selection.set` params selecting `points`: their paths, and those points.+fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)]) -> Value {+    let mut ids: Vec<u64> = cx.selection.items.iter().map(|i| i.0).collect();+    for (i, _, _) in points {+        if !ids.contains(&i.0) {+            ids.push(i.0);+        }+    }+    let anchors: Vec<Value> = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect();+    json!({"ids": ids, "anchors": anchors})+}++/// `d` held to the nearest multiple of 45°, as Shift does.+fn constrain_45(d: Vec2) -> Vec2 {+    if d.hypot() < 1e-9 {+        return d;+    }+    let step = std::f64::consts::FRAC_PI_4;+    let a = (d.y.atan2(d.x) / step).round() * step;+    let proj = d.x * a.cos() + d.y * a.sin();+    Vec2::new(proj * a.cos(), proj * a.sin())+}+ pub struct SelectionTool {     direct: bool,     drag: Drag,@@ -224,6 +301,8 @@ pub struct SelectionTool {     guides: Vec<Overlay>,     /// Selected item to toggle if this Shift press never becomes a drag.     shift_release: Option<u64>,+    /// Selected anchor point to take away if this Shift press never becomes a drag.+    anchor_release: Option<(ItemId, usize, usize)>,     /// One of several selected items to make (or stop being) the key object for Align if this     /// press never becomes a drag.     key_release: Option<u64>,@@ -235,7 +314,17 @@ pub struct SelectionTool {  impl SelectionTool {     pub fn new(direct: bool) -> Self {-        Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None }+        Self {+            direct,+            drag: Drag::None,+            hover_handle: None,+            guides: vec![],+            shift_release: None,+            key_release: None,+            anchor_release: None,+            loaded: None,+            live: None,+        }     }      /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the@@ -750,8 +839,51 @@ impl Tool for SelectionTool {                     && let Some((id, si, ai, handle)) = anchor_at(cx, p)                 {                     let at = anchor_spread_point(cx, id, si, ai);-                    self.drag = Drag::Anchor { id, si, ai, handle, start: p, at };-                    return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })];+                    let mut out = vec![];+                    let mut others = vec![];+                    if handle.is_none() {+                        // InDesign's anchor point selection: a click selects the point, Shift+click+                        // adds it or takes it away, and dragging a selected point moves all of them.+                        let me = (ItemId(id), si, ai);+                        let mut points = cx.selection.anchors.clone();+                        let was = points.contains(&me);+                        if ev.mods.shift && was {+                            // Kept selected so a Shift-drag can constrain it; a click still takes it+                            // away, on release, as for objects.+                            self.anchor_release = Some(me);+                        } else if ev.mods.shift {+                            points.push(me);+                        } else if !was {+                            points = vec![me];+                        }+                        if points != cx.selection.anchors {+                            out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points)));+                        }+                        others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect();+                    }+                    let label = if handle.is_some() {+                        "Move Direction Handle"+                    } else if others.is_empty() {+                        "Move Anchor"+                    } else {+                        "Move Anchors"+                    };+                    self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others };+                    out.push(Action::Begin(label.into()));+                    return out;+                }+                // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object+                // that isn't selected selects it and moves just that point (Chris Nielsen's "The+                // InDesign Toolbox", 10:10: click outside, then pull a frame's corner).+                if self.direct+                    && !ev.mods.shift+                    && let Some((_, hit)) = cx.hit(p)+                    && !cx.selection.contains(hit)+                    && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p)+                {+                    let at = anchor_spread_point(cx, id, si, ai);+                    self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] };+                    return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())];                 }                 // A selected placed graphic's own handles resize it inside its frame (the                 // frame's anchors, under Direct Selection, come first).@@ -1084,8 +1216,18 @@ impl Tool for SelectionTool {                     // Screen y points down: a positive screen angle is clockwise.                     vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))]                 }-                Drag::Anchor { id, si, ai, handle, start, at } => {-                    let d = p - start;+                Drag::Anchor { id, si, ai, handle, start, at, others } => {+                    let mut d = p - start;+                    if d.hypot() >= cx.tol(3.0) {+                        self.anchor_release = None;+                    } else if self.anchor_release.is_some() {+                        // A Shift-press on a selected point is still a click: nothing moves yet.+                        return vec![];+                    }+                    // Shift: horizontal, vertical or at 45°.+                    if ev.mods.shift {+                        d = constrain_45(d);+                    }                     let mut dx = d.x;                     let mut dy = d.y;                     self.guides.clear();@@ -1113,18 +1255,23 @@ impl Tool for SelectionTool {                             },                         );                         // dx and dy stay in canvas space. Snap.delta is spread space.-                        let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta);-                        if corr.x.is_finite() {-                            dx += corr.x;-                        }-                        if corr.y.is_finite() {-                            dy += corr.y;+                        let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta);+                        corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 });+                        // Shift: the snap moves the points along the constrained direction only.+                        if ev.mods.shift && d.hypot() > 1e-9 {+                            let dir = d / d.hypot();+                            corr = dir * corr.dot(dir);                         }+                        dx += corr.x;+                        dy += corr.y;                         self.guides = hit.guides;                     }                     let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy});                     if let Some(h) = handle {                         params["handle"] = json!(h);+                    } else if !others.is_empty() {+                        let points: Vec<Value> = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect();+                        params = json!({"points": points, "dx": dx, "dy": dy});                     }                     vec![Action::Preview("path.moveAnchors".into(), params)]                 }@@ -1133,9 +1280,15 @@ impl Tool for SelectionTool {             PointerKind::Up => {                 self.guides.clear();                 let release = self.shift_release.take();+                let anchor_release = self.anchor_release.take();                 let key = self.key_release.take();                 let d = std::mem::replace(&mut self.drag, Drag::None);                 match d {+                    // A Shift-click on a selected point that never became a drag takes it away.+                    Drag::Anchor { .. } if anchor_release.is_some() => {+                        let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect();+                        vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points))]+                    }                     Drag::Move { .. }                     | Drag::Resize { .. }                     | Drag::ContentResize { .. }@@ -1152,6 +1305,21 @@ impl Tool for SelectionTool {                     },                     Drag::Marquee { start, cur } => {                         let r = Rect::from_points(start, cur);+                        // Direct Selection: the anchor points inside the marquee, on every path it+                        // reaches (InDesign's way to select points of several frames at once).+                        if self.direct {+                            let mut points = anchors_in(cx, r);+                            if !points.is_empty() {+                                if ev.mods.shift {+                                    for a in &cx.selection.anchors {+                                        if !points.contains(a) {+                                            points.push(*a);+                                        }+                                    }+                                }+                                return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points))];+                            }+                        }                         // Items whose bounds intersect the marquee.                         let mut ids: Vec<Value> = Vec::new();                         for slot in &cx.layout.slots {@@ -1188,7 +1356,15 @@ impl Tool for SelectionTool {         if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) {             return vec![Action::Handled];         }-        let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];+        let points: Vec<Value> = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect();+        let direct_points = self.direct && !points.is_empty();+        let mv = |dx: f64, dy: f64| {+            // Direct Selection with anchor points selected: the arrow keys move the points.+            if direct_points {+                return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))];+            }+            vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]+        };         if cx.selection.items.is_empty() {             // Selected ruler guides: the arrows nudge them, Delete deletes them.             if cx.selection.guides.is_empty() {@@ -1333,3 +1509,26 @@ fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> {     let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p));     Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) }++#[cfg(test)]+mod handle_tests {+    use super::handle_shown;++    /// InDesign's Direct Selection shows a selected anchor's two handles, and on its neighbours+    /// the handle shaping the segment to it; nothing with no anchor selected.+    #[test]+    fn handles_show_around_the_selected_anchors() {+        let shown = |closed: bool, chosen: &[usize]| {+            (0..4)+                .flat_map(|ai| [false, true].map(|out| (ai, out)))+                .filter(|(ai, out)| handle_shown(4, closed, *ai, *out, |a| chosen.contains(&a)))+                .collect::<Vec<_>>()+        };+        assert_eq!(shown(true, &[]), vec![], "no anchor selected");+        assert_eq!(shown(true, &[2]), vec![(1, true), (2, false), (2, true), (3, false)]);+        // Closed, the first anchor's neighbours wrap round.+        assert_eq!(shown(true, &[0]), vec![(0, false), (0, true), (1, false), (3, true)]);+        // Open, the end has no neighbour beyond it.+        assert_eq!(shown(false, &[0]), vec![(0, false), (0, true), (1, false)]);+    }+}diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs--- a/crates/tools/src/tests.rs+++ b/crates/tools/src/tests.rs@@ -1792,7 +1792,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() {         let p = Point::new(x + off.x, 300.0 + off.y);         let mut direct = create("directSelection");         assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}");-        assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]);+        // The press selects that anchor point and starts moving it.+        let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));+        assert!(+            matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)),+            "{a:?}"+        );+        assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into())));     } } @@ -1936,3 +1942,65 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {     assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group");     assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); }++/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of+/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant+/// them (Zero2Graphic's page layout tutorial, 1:15).+#[test]+fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() {+    let mut d = Document::new(&NewDocument::default());+    let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));+    let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0));+    let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));+    let none = Selection::default();+    let mut t = create("directSelection");+    // A marquee around the top edges: the four top points, not the frames' bottom ones.+    let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0));+    t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y));+    t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y));+    let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y));+    let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") };+    assert_eq!(cmd, "selection.set");+    assert_eq!(p["ids"], serde_json::json!([a.0, b.0]));+    let mut got: Vec<(u64, u64, u64)> =+        p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect();+    got.sort();+    assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]);+    // Those points selected: dragging one moves all four, in one preview.+    let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() };+    let cx = ctx(&d, &sel, &c, &l);+    let at = spread_pt(&l, 200.0, 100.0);+    let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));+    assert_eq!(down, vec![Action::Begin("Move Anchors".into())]);+    let shift = Mods { shift: true, ..Default::default() };+    let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift));+    let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") };+    assert_eq!(cmd, "path.moveAnchors");+    assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");+    // Shift keeps the move horizontal.+    assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0)));+    assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]);+    // A Shift-drag that starts on a selected point keeps it: all four move, constrained.+    t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));+    let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift));+    let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") };+    assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");+    assert!(p["dx"].as_f64().unwrap().abs() < 1e-9 && (p["dy"].as_f64().unwrap() - 30.0).abs() < 1e-9, "vertical: {p}");+    assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]);+    // Shift+click on a selected point takes it out of the selection, on release; the arrow keys+    // move the points.+    let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));+    assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}");+    // A jiggle inside the drag tolerance moves nothing: it is still a click.+    let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift));+    assert!(jiggle.is_empty(), "{jiggle:?}");+    let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift));+    let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") };+    assert_eq!(cmd, "selection.set");+    assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}");+    let keys = t.key(&cx, ToolKey::Right, Mods::default());+    assert!(+        matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)),+        "{keys:?}"+    );+}diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1243,14 +1243,16 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum     }     let mut union: Option<Rect> = None;     let mut color = Color32::from_rgb(79, 153, 255);+    let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");     for id in &sel.items {         let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };         color = layer_color(doc, it);         for poly in path_screen(it, xf, a) {             painter.add(egui::Shape::line(poly, Stroke::new(1.0, color)));         }-        if sel.items.len() == 1 && sel.text.is_none() {-            // InDesign draws a 1 pt bounding box around the selection.+        // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection tool,+        // only the path and its points.+        if sel.items.len() == 1 && sel.text.is_none() && !direct {             painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);         } else if sel.key == Some(*id) {             // The key object for Align: a thicker bounding box, as in InDesign.@@ -1281,16 +1283,26 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum             }         }     }-    let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");+    // Direct Selection shows only the handles around the selected anchors, as InDesign; the pen+    // and the anchor tools show every handle they work on.+    let some_handles = app.session.tool_id() == "directSelection";     if direct {+        // Looked up once per anchor drawn: a large marquee selection stays linear.+        let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect();         for id in &sel.items {             let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };             let m = a * it.xf;             let col = layer_color(doc, it);-            for sp in &it.path.subpaths {-                for an in &sp.anchors {+            for (si, sp) in it.path.subpaths.iter().enumerate() {+                for (ai, an) in sp.anchors.iter().enumerate() {                     let p = xf.to_screen(m * an.p);-                    for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] {+                    // A selected anchor point is solid, the others hollow (InDesign).+                    let chosen = chosen_set.contains(&(*id, si, ai));+                    let shown = |out: bool| {+                        !some_handles+                            || designcraft_tools::select::handle_shown(sp.anchors.len(), sp.closed, ai, out, |a| chosen_set.contains(&(*id, si, a)))+                    };+                    for (h, has) in [(an.h_in, an.has_in() && shown(false)), (an.h_out, an.has_out() && shown(true))] {                         if has {                             let hp = xf.to_screen(m * h);                             painter.line_segment([p, hp], Stroke::new(1.0, col));@@ -1298,7 +1310,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum                         }                     }                     let r = Rect::from_center_size(p, vec2(5.0, 5.0));-                    painter.rect_filled(r, 0.0, Color32::WHITE);+                    painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE });                     painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);                 }             }