diff --git a/ROADMAP.md b/ROADMAP.md index 095effac..5d5b07b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -113,7 +113,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected). - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** - - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). + - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index c9669342..1d7a43d5 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -27,10 +27,26 @@ pub fn specs() -> Vec { cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)), cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set), cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add), - cmd!("select.toggle", "Toggle Selection", [], None, "{id}", has_doc, toggle), + cmd!( + "select.toggle", + "Toggle Selection", + [], + None, + "{id} or {ids: [id…]}: each selected one leaves the selection, the others join it", + has_doc, + toggle + ), cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key), cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), - cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many), + cmd!( + "select.anchorsMany", + "Select Anchors", + [], + None, + "{items: [{id, anchors}], mode?: \"set\"|\"add\"|\"toggle\"|\"subtract\" (set by default; add: true is mode add)}: toggle selects the anchors not selected and deselects the others, subtract deselects them; a path left with none leaves the selection", + has_doc, + anchors_many + ), cmd!( "select.same.fillColor", "Fill Color", @@ -217,8 +233,12 @@ fn add(s: &mut Session, p: &Value) -> Result { } fn toggle(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("select.toggle", "missing id"))?; - s.select(|_, sel| sel.toggle(id))?; + let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?; + s.select(|_, sel| { + for id in ids { + sel.toggle(id); + } + })?; ok() } @@ -262,16 +282,48 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect()) .unwrap_or_default(); - let add = bool_or(p, "add", false); + // `add: true` is the older spelling of mode add. + let mode = match str_param(p, "mode") { + Some(m @ ("set" | "add" | "toggle" | "subtract")) => m, + Some(m) => return Err(bad("select.anchorsMany", format!("unknown mode {m:?}"))), + None if bool_or(p, "add", false) => "add", + None => "set", + }; s.select(|d, sel| { - if !add { + if mode == "set" { sel.clear(); } for (id, refs) in items { - let total = d.node(id).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0); + let all: BTreeSet<(usize, usize)> = + d.node(id).and_then(|n| n.path_data()).map(|p| p.anchors().map(|(s, i, _)| (s, i)).collect()).unwrap_or_default(); + // Anything but a path is selected, toggled or deselected as a whole. + if all.is_empty() { + match mode { + "toggle" => sel.toggle(id), + "subtract" => sel.remove(id), + _ => sel.add(id), + } + continue; + } + // From the anchors selected now (all of them for a path selected as a whole). + let now = match sel.partial(id) { + Some(a) => a.clone(), + None if sel.contains(id) => all.clone(), + None => BTreeSet::new(), + }; + let refs: BTreeSet<(usize, usize)> = refs.intersection(&all).copied().collect(); + let next: BTreeSet<(usize, usize)> = match mode { + "toggle" => now.symmetric_difference(&refs).copied().collect(), + "subtract" => now.difference(&refs).copied().collect(), + _ => now.union(&refs).copied().collect(), + }; + if next.is_empty() { + sel.remove(id); + continue; + } sel.add(id); - if refs.len() < total { - sel.anchors.entry(id).or_default().extend(refs); + if next.len() < all.len() { + sel.anchors.insert(id, next); } else { sel.anchors.remove(&id); } diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 03f1ee55..e790a65b 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -338,6 +338,72 @@ fn lasso_selects_anchor_subset() { assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2)); } +/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are +/// deselected and the others selected, the rest of the selection staying as it was. +#[test] +fn selection_shift_marquee_toggles_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0); + let c = rect(&mut s, 300.0, 100.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let shift = Mods { shift: true, ..Default::default() }; + gesture(&mut s, "selection", &[(180.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, c]); + // Again over all three: a leaves, b joins, c leaves. + gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (380.0, 200.0)], shift); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + // Without Shift the marquee replaces the selection. + gesture(&mut s, "selection", &[(80.0, 80.0), (300.0, 200.0), (260.0, 200.0)], Mods::default()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); +} + +/// Shift-drag a marquee with Direct Selection (#483): the anchors inside toggle; a path left with +/// none of them leaves the selection, one with all of them is selected whole again. +#[test] +fn direct_selection_shift_marquee_toggles_anchors() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let shift = Mods { shift: true, ..Default::default() }; + // Around a's top edge (two anchors, selected) and b's top-left anchor (not selected). + let top = [(90.0, 90.0), (310.0, 110.0), (310.0, 110.0)]; + gesture(&mut s, "directSelection", &top, shift); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, vec![a, b]); + assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2), "a's bottom anchors stay"); + assert_eq!(st.selection.partial(b).map(|p| p.len()), Some(1)); + // The same again: a whole once more, b out. + gesture(&mut s, "directSelection", &top, shift); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, vec![a]); + assert_eq!(st.selection.partial(a), None); + // Group Selection's marquee toggles the same way. + gesture(&mut s, "groupSelection", &top, shift); + assert_eq!(s.doc().unwrap().selection.partial(a).map(|p| p.len()), Some(2)); +} + +/// The Lasso: Shift adds anchors, Alt takes them away, and neither drops the rest of the +/// selection (a path selected whole stays whole when its anchors are added again). +#[test] +fn lasso_shift_adds_and_alt_subtracts() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let t = s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Hi"})).unwrap(); + let t = NodeId(t["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); + // A loop round a's top-left anchor. + let corner = [(90.0, 90.0), (110.0, 90.0), (110.0, 110.0), (90.0, 110.0), (90.0, 110.0)]; + gesture(&mut s, "lasso", &corner, Mods { shift: true, ..Default::default() }); + let st = s.doc().unwrap(); + assert_eq!((st.selection.objects.clone(), st.selection.partial(a)), (vec![a, t], None)); + gesture(&mut s, "lasso", &corner, Mods { alt: true, ..Default::default() }); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, vec![a, t], "the type stays selected"); + assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(3)); +} + #[test] fn measure_tool_leaves_document_untouched() { let mut s = session(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6505e416..c85cda5b 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -180,6 +180,10 @@ fn structured_junk() { ("select.add", json!({"ids": [[1], {"a": 1}]})), ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), + ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})), + ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 0]]}], "mode": "subtract"})), + ("select.anchorsMany", json!({"items": [], "mode": 7})), + ("select.toggle", json!({"ids": [2, u64::MAX, "x"]})), ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), ("object.transform", json!({"matrix": [1, 2, 3]})), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ca403536..58e43789 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -539,6 +539,30 @@ fn selection_preferences_apply_to_pointer_gestures() { assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does"); } +/// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the +/// objects it reaches, so a selected one leaves the selection and an unselected one joins it. +#[test] +fn shift_marquee_gesture_toggles_the_selection() { + let mut s = server(); + let square = |s: &mut Server, id: u64, x: u64| { + let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50})); + serde_json::from_str::(&text_of(&r)).unwrap()["id"].as_u64().unwrap() + }; + let (a, b, c) = (square(&mut s, 1, 100), square(&mut s, 2, 200), square(&mut s, 3, 300)); + let r = call(&mut s, 4, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}})); + assert_eq!(r["isError"], false, "{r}"); + let events = json!([{"kind": "down", "x": 180, "y": 80}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 380, "y": 200}]); + for tool in ["selection", "directSelection"] { + let r = call(&mut s, 5, "pointer_gesture", json!({"tool": tool, "events": events, "mods": {"shift": true}})); + assert_eq!(r["isError"], false, "{r}"); + let sel = &serde_json::from_str::(&text_of(&r)).unwrap()["selection"]; + // The Selection tool takes b out and adds c; Direct Selection's marquee then toggles their + // anchors back: b's are selected again and c, whole, leaves. + let want = if tool == "selection" { json!([a, c]) } else { json!([a, b]) }; + assert_eq!(*sel, want, "{tool}"); + } +} + /// Type preferences reach agents (#394): type the Type tool places starts with placeholder text, /// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading. #[test] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index cf4f2616..0f2f5055 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -5,10 +5,12 @@ //! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45° //! steps), 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, drag a live rectangle's corner widget to round its corners +//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are +//! deselected, the others selected), drag a live rectangle's corner widget to round its corners //! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens //! the Corners dialog). -//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. +//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group; +//! its marquee works as Direct Selection's. //! Both move objects selected as a whole as the Selection tool does, snapping alike. //! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]). //! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves @@ -69,7 +71,8 @@ enum State { Marquee { start: Point, cur: Point, - add: bool, + /// Shift: the anchors inside leave the selection if selected, else join it. + toggle: bool, }, Corner(CornerDrag), /// Dragging a bracket of type on a path. @@ -285,7 +288,7 @@ impl Tool for DirectSelectionTool { return out; } let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { - self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift }; + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift }; return vec![]; }; // Walk up from the leaf: select the first ancestor not yet selected (below the layer). @@ -393,7 +396,7 @@ impl Tool for DirectSelectionTool { } return vec![]; } - self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift }; + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift }; vec![] } (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => { @@ -497,8 +500,8 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; self.mesh.release().unwrap_or_default() } - (PointerKind::Drag, State::Marquee { start, add, .. }) => { - self.state = State::Marquee { start, cur: p, add }; + (PointerKind::Drag, State::Marquee { start, toggle, .. }) => { + self.state = State::Marquee { start, cur: p, toggle }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -537,11 +540,11 @@ impl Tool for DirectSelectionTool { self.guides.clear(); vec![Action::Commit] } - (PointerKind::Up, State::Marquee { start, add, .. }) => { + (PointerKind::Up, State::Marquee { start, toggle, .. }) => { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { - return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } // Collect anchors inside the rect for every editable path. let mut sel: Vec<(NodeId, Vec)> = vec![]; @@ -555,7 +558,7 @@ impl Tool for DirectSelectionTool { }); sel.retain(|(id, _)| cx.doc.is_editable(*id)); let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); - vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "add": add}))] + vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))] } _ => vec![], } @@ -792,8 +795,25 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); } + /// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. + #[test] + fn shift_marquee_toggles_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]); + } + } + /// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f65b7af5..1742cf49 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,4 +1,5 @@ -//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains; Smart +//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches: +//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart //! Guides, or with them off View › Snap to Point, snap it), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind, @@ -49,7 +50,8 @@ enum State { Marquee { start: Point, cur: Point, - add: bool, + /// Shift: the objects inside leave the selection if selected, else join it. + toggle: bool, }, /// Dragging a Live Corners widget. Corner(CornerDrag), @@ -256,7 +258,7 @@ impl Tool for SelectionTool { out } None => { - self.state = State::Marquee { start: p, cur: p, add: m.shift }; + self.state = State::Marquee { start: p, cur: p, toggle: m.shift }; vec![] } } @@ -299,8 +301,8 @@ impl Tool for SelectionTool { self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg))); vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] } - (PointerKind::Drag, State::Marquee { start, add, .. }) => { - self.state = State::Marquee { start, cur: p, add }; + (PointerKind::Drag, State::Marquee { start, toggle, .. }) => { + self.state = State::Marquee { start, cur: p, toggle }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -339,18 +341,14 @@ impl Tool for SelectionTool { self.targets = None; vec![Action::Commit] } - (PointerKind::Up, State::Marquee { start, add, .. }) => { + (PointerKind::Up, State::Marquee { start, toggle, .. }) => { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { - return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; + return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let ids: Vec = marquee(cx.doc, r, cx.isolation, false); - if add { - vec![Action::Exec("select.add".into(), json!({ "ids": json_ids(&ids) }))] - } else { - vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))] - } + vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))] } _ => vec![], } @@ -556,6 +554,24 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } + /// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be + /// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. + #[test] + fn shift_marquee_toggles() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0).with_mods(shift)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(shift)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty()); + } + #[test] fn handle_drag_scales() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/xform/wand.rs b/crates/tools/src/xform/wand.rs index 5375f52d..68f876e9 100644 --- a/crates/tools/src/xform/wand.rs +++ b/crates/tools/src/xform/wand.rs @@ -115,25 +115,15 @@ impl Tool for LassoTool { } }); hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id)); - if sub { - // Current anchor selection minus the lassoed anchors. - let mut items = vec![]; - for id in &cx.selection.objects { - let Some(path) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; - let cur: BTreeSet = match cx.selection.partial(*id) { - Some(s) => s.clone(), - None => path.anchors().map(|(s, i, _)| (s, i)).collect(), - }; - let remove = hits.iter().find(|(h, _)| h == id).map(|(_, v)| v.clone()).unwrap_or_default(); - let rest: BTreeSet = cur.difference(&remove).copied().collect(); - if !rest.is_empty() { - items.push(json!({ "id": id.0, "anchors": anchors_json(&rest) })); - } - } - return vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": false }))]; - } let items: Vec = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect(); - vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": add }))] + let mode = if sub { + "subtract" + } else if add { + "add" + } else { + "set" + }; + vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "mode": mode }))] } _ => vec![], } @@ -198,7 +188,7 @@ mod tests { assert_eq!(c, "select.anchorsMany"); assert_eq!(v["items"][0]["id"], id.0); assert_eq!(v["items"][0]["anchors"].as_array().unwrap().len(), 1); - assert_eq!(v["add"], false); + assert_eq!(v["mode"], "set"); assert!(point_in_polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], Point::new(2.0, 2.0))); } } diff --git a/docs/mcp.md b/docs/mcp.md index 583a95e0..f1242690 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -189,6 +189,16 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | | +With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave +the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors +inside it (a path left with none leaves the selection, one with all of them is selected whole). The Lasso adds anchors +with `mods.shift` and takes them away with `mods.alt`. They run `select.toggle {ids}` and `select.anchorsMany {items, +mode: "set"|"add"|"toggle"|"subtract"}`: + +```json +{"name":"pointer_gesture","arguments":{"tool":"selection","mods":{"shift":true},"events":[{"kind":"down","x":180,"y":80},{"kind":"drag","x":300,"y":200},{"kind":"up","x":380,"y":200}]}} +``` + Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`, `stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take