diff --git a/ROADMAP.md b/ROADMAP.md index 24893204b..46df1b495 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1cc1203e5..d10bdd3d5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1366,10 +1366,17 @@ impl Session { let Some(mut it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview.take() else { return Ok(()) }; let perspective_again = it.perspective_again.take(); + let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); + // Alt pressed or released during a move drag turns it into a copy or back: name it for + // what it did in the end. + match (it.label.as_str(), copy) { + ("Move", true) => it.label = "Copy".into(), + ("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(), + _ => {} + } st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); - let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); if let Some(m) = m { st.last_transform = Some((m, copy)); st.last_perspective = None; diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 826d9613e..832e17226 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -355,3 +355,19 @@ fn an_undo_group_is_one_undo_step() { assert!((width(&s) - 120.0).abs() < 1e-9); assert_eq!(undo_len(&s), undo + 2); } + +/// A move drag is named for what its last preview did: Alt pressed or released during the drag +/// turns it into a copy or back (the Selection and Direct Selection tools). +#[test] +fn a_move_drag_is_named_for_whether_it_copied() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] { + s.begin_interaction(begin).unwrap(); + s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}"); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 739423b27..5b72592d2 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -3,7 +3,8 @@ //! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the //! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to //! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45° -//! steps), drag a direction handle to +//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a +//! direction handle to //! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap //! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are //! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners @@ -28,7 +29,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_doc::hit::{HitKind, hit_test}; -use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Anchor, PathData, Point, Rect}; use crate::bbox::move_delta; @@ -50,8 +51,11 @@ enum State { grab: Point, began: bool, }, + /// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the + /// path by, which snaps as a dragged anchor does. MoveObject { start: Point, + grab: Option, began: bool, }, /// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`). @@ -113,6 +117,9 @@ pub struct DirectSelectionTool { guides: Vec, /// What the dragged anchors snap to, gathered when the drag begins. anchor_snap: Option, + /// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left + /// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original. + copy_snap: Option, /// What the dragged handle snaps to. handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. @@ -131,6 +138,7 @@ impl DirectSelectionTool { mesh: MeshEdit::default(), guides: vec![], anchor_snap: None, + copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, hover: None, @@ -319,7 +327,7 @@ impl Tool for DirectSelectionTool { } else { h.leaf }; - self.state = State::MoveObject { start: p, began: false }; + self.state = State::MoveObject { start: p, grab: None, began: false }; if cx.selection.contains(target) { vec![] } else if ev.mods.shift || cx.selection.contains(h.leaf) { @@ -368,8 +376,21 @@ impl Tool for DirectSelectionTool { if cx.doc.node(id).is_some_and(is_area_type) { return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); } - let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai))); - self.state = State::MoveAnchors { start: p, grab, began: false }; + // Alt picks the whole path (and Alt-drag copies it), as on a segment. + if ev.mods.alt { + self.state = State::MoveObject { start: p, grab: Some(grab), began: false }; + let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); + return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; + } + // A path with all its anchors selected is stored whole (no partial entry). + let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); + // Paths all selected whole move as objects, held by this anchor, so Alt can turn + // the copy on and off during the drag as with the Selection tool. + self.state = if already && cx.selection.anchors.is_empty() { + State::MoveObject { start: p, grab: Some(grab), began: false } + } else { + State::MoveAnchors { start: p, grab, began: false } + }; if ev.mods.shift { return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; } @@ -409,7 +430,7 @@ impl Tool for DirectSelectionTool { return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))]; } // Clicking the fill selects the whole leaf path (all anchors). - self.state = State::MoveObject { start: p, began: false }; + self.state = State::MoveObject { start: p, grab: None, began: false }; if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } @@ -459,19 +480,31 @@ impl Tool for DirectSelectionTool { )); out } - (PointerKind::Drag, State::MoveObject { start, began }) => { + (PointerKind::Drag, State::MoveObject { start, grab, began }) => { let mut out = vec![]; if !began { if p.distance(start) < cx.tol(3.0) { return out; } - out.push(Action::Begin("Move".into())); - self.state = State::MoveObject { start, began: true }; - self.move_snap = Some(MoveSnap::new(cx)); + out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); + self.state = State::MoveObject { start, grab, began: true }; + if grab.is_some() { + // Alt can go down or up at any time: gather both while nothing has moved. + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + } else { + self.move_snap = Some(MoveSnap::new(cx)); + } } let mut d = move_delta(start, p, ev.mods.shift); - if let Some(snap) = &self.move_snap { - (d, self.guides) = snap.snap(cx, start, d); + self.guides.clear(); + let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; + if let (Some(grab), Some(snap)) = (grab, snap) { + let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + (d, self.guides) = (q - grab, guides); + } else if let Some(snap) = &self.move_snap { + (d, self.guides) = snap.snap(cx, start, d, ev.mods.alt); } out.push(Action::Preview( "object.transform".into(), @@ -554,7 +587,7 @@ impl Tool for DirectSelectionTool { ) => { self.state = State::Idle; self.guides.clear(); - (self.anchor_snap, self.move_snap) = (None, None); + (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { @@ -838,6 +871,45 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); } + /// Pressing an anchor of a path whose anchors are all selected (stored as the whole path) + /// keeps them all selected, so the drag moves them all; Shift still deselects that anchor. + #[test] + fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); + let shift = Mods { shift: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); + } + + /// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies + /// it as the Selection tool's Alt-drag does. + #[test] + fn alt_dragging_an_anchor_copies_the_whole_path() { + let (d, id) = doc_with_rect(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([id]); + let p = paint(); + for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] { + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt)); + let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(a, if pick { set } else { vec![] }, "{s:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + } + } + #[test] fn marquee_selects_anchors() { let (d, id) = doc_with_rect(); @@ -1037,6 +1109,74 @@ mod tests { (v.clone(), labels) } + /// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does: + /// onto another path's anchor, or onto an anchor of the original the copy leaves behind. + #[test] + fn alt_dragged_copies_snap_by_the_anchor_pressed() { + let (d, _, b) = two_squares(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let copy = + |dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true}); + // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. + let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt); + assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()])); + // B's top-left corner onto the original B's top-right one: the copy sits beside it. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt); + assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()])); + // The history says what the drag did. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt)); + assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); + } + + /// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released + /// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed + /// snaps all along, onto the original only while it is left behind. + #[test] + fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { + let (d, _, b) = two_squares(); + let p = paint(); + let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let preview = |dx: f64, dy: f64, copy: bool| { + Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), + ) + }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), + vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] + ); + // The tool sees the document as previewed: B moved along, not where the copy leaves it. + let (mut moved, _, b2) = two_squares(); + let l = moved.layers[0].id; + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); + moved.remove(b2).unwrap(); + moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let c = cx(&moved, &whole, &p); + // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); + // Alt up: a move again, which leaves nothing behind to snap to. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); + // With only some anchors selected, the drag still moves just those. + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); + } + /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors /// of its own path, and with several selected the one pressed on snaps, the others following. #[test] diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 065fa41bd..951971537 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -179,10 +179,11 @@ impl Targets { Self::collect_inner(doc, exclude, None, visible, Gather::Bounds) } - /// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with - /// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). - pub fn for_move(cx: &ToolContext) -> Self { - let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx); + /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, + /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › + /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). + pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { + let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } @@ -1229,7 +1230,7 @@ mod tests { } let (s, p) = (Selection::default(), paint()); let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); - let t = Targets::for_move(&cx(&d, &s, &p)); + let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); assert_eq!(dv, Vec2::new(-1.0, 0.0)); @@ -1241,7 +1242,7 @@ mod tests { let id = wide.alloc_id(); wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) .unwrap(); - let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); + let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); assert_eq!(dv, Vec2::new(2.0, 0.0)); assert!(lines(&ov) >= 6, "{ov:?}"); // A column: 28 under D, as C and D are 30 apart. @@ -1253,7 +1254,7 @@ mod tests { assert_eq!(dv, Vec2::new(0.0, 2.0)); // Off: nowhere to go, and no marks. let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; - let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 21934d78b..511a67257 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -79,36 +79,47 @@ pub struct SelectionTool { /// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart /// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the /// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole -/// pixels. +/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original +/// behind, so it snaps to the original too. pub(crate) struct MoveSnap { bounds: Option, targets: Option, points: Option, + /// The targets and points for a copy, gathered with the others before the previews move the + /// original. + copy_targets: Option, + copy_points: Option, } impl MoveSnap { pub(crate) fn new(cx: &ToolContext) -> Self { + let guides = cx.smart_guides && !cx.snap_to_grid; + let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) }; Self { bounds: selection_bounds(cx), - targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)), - points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) }, + targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)), + points: points(&cx.selection.objects), + copy_targets: guides.then(|| Targets::for_move(cx, &[])), + copy_points: points(&[]), } } - /// The move by `d` of the selection grabbed at `start`, snapped, and its guides. - pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec) { + /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its + /// guides. + pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { + let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds { d += grid_pull(b + d, cx.grid_step()); } - if let Some(t) = &self.points { + if let Some(t) = points { let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance)); d = q - start; guides = ov; } - if let (Some(t), Some(b)) = (&self.targets, self.bounds) { + if let (Some(t), Some(b)) = (targets, self.bounds) { let (adj, ov) = t.snap_rect(b + d, cx.snap_tol()); d += adj; guides = ov; @@ -331,7 +342,7 @@ impl Tool for SelectionTool { } let mut d = move_delta(start, p, m.shift); if let Some(snap) = &self.moving { - (d, self.guides) = snap.snap(cx, start, d); + (d, self.guides) = snap.snap(cx, start, d, m.alt); } self.state = State::Moving { start, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); @@ -622,6 +633,38 @@ mod tests { assert_eq!(a, vec![Action::Commit]); } + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as + /// gathered before the previews moved it); released, the move doesn't. + #[test] + fn an_alt_copy_snaps_to_the_original_left_behind() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and centre are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + let dx = |a: &[Action]| { + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0); + // The previews moved the rectangle: the tool sees it 50 pt along. + let mut moved = d.clone(); + let l = moved.layers[0].id; + moved.remove(id).unwrap(); + let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)); + moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap(); + let c = cx(&moved, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + // The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0); + // Without Alt nothing stays behind there. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); + } + /// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by /// whichever of its edges or its centre is nearest a gridline, on each axis. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d9a71f1c2..05ff7230b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2508,8 +2508,10 @@ mod tests { used }; let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); - // Direct Selection used last: the drag moves the rectangle's bottom-right corner only. + // Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once + // the rectangle isn't selected whole, which would move all its anchors). app.select_tool("directSelection"); + app.session.execute("select.none", &json!({})).unwrap(); let corner = xf.to_screen(Point::new(200.0, 150.0)); assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection"); let b = bounds(&app); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ce279c81d..3321d9849 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist: pen, anchors, handles, snapping, nudging, modifiers, panels) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Direct Selection: whole-path drags by an anchor and Alt-drag copies, #1104; earlier: first checklist) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -63,6 +63,7 @@ tools, never checked side by side) isn't captured row by row. |---|---|---| | Click/Shift-click anchors; marquee toggles with Shift | done | #483 | | Drag a curved segment to bend it, a straight one to move its anchors | done | | +| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 | | Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 | | Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides | | Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display | @@ -166,4 +167,5 @@ re-measure of the panels added since. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) | | 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |