diff --git a/ROADMAP.md b/ROADMAP.md index 3c6b47e7..ffeada5b 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-09 (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 import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), 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), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (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 import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), 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), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 91784cc4..0651a85f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -159,7 +159,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_selection, align ), @@ -177,7 +177,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_multi, distribute_spacing ), @@ -813,9 +813,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() } +/// What `object.align` aligns to: `to`, else the key object when there is one, else the +/// selection. +fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { + str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key")) +} + fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { let st = s.doc()?; - match str_param(p, "to") { + match align_to(s, p) { Some("artboard") => { let b = st.doc.bounds_of(ids, preview).unwrap_or_default(); let i = st.doc.artboard_at(b.center()).unwrap_or(0); @@ -840,7 +846,7 @@ fn align(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; items_bounds(d, &ids, preview) .into_iter() - .filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key")) + .filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key")) .map(|(id, b)| { let dx = match h { Some("left") => r.x0 - b.x0, @@ -926,22 +932,31 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let mut items = items_bounds(&s.doc()?.doc, &ids, preview); items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let n = items.len(); + let &[(_, first), .., (_, last)] = items.as_slice() else { + return Err(EngineError::Other("select two or more objects to distribute".into())); + }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - let gap = match p.get("spacing").and_then(Value::as_f64) { + let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite()); + let gap = match spacing { Some(g) => g, None => { - let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 }; + let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 }; (span - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64 } }; - let mut pos = start(&items[0].1); - let mut moves = vec![]; + let mut pos = start(&first); + let mut deltas = vec![]; for (id, r) in &items { - let delta = pos - start(r); - moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })); + deltas.push((*id, pos - start(r))); pos += size(r) + gap; } + // With a spacing (aligning to a key object) the key object stays where it is: the others + // are spaced from it. + let key = s.doc()?.selection.key; + let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); + let moves: Vec<(NodeId, Vec2)> = + deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); s.edit("Distribute Spacing", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 1d7a43d5..27d28684 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -36,7 +36,15 @@ pub fn specs() -> Vec { has_doc, toggle ), - cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key), + cmd!( + "select.key", + "Set Key Object", + [], + None, + "{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it", + has_doc, + key + ), cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), cmd!( "select.anchorsMany", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c4480abf..c97b572b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -198,6 +198,37 @@ fn align_left() { assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0); } +/// #541: with the Selection tool, a click on one object of the selection makes it the key object: +/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click +/// on the key again lets it go. +#[test] +fn a_click_sets_the_key_object_that_align_and_spacing_keep_still() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0); + let c = rect(&mut s, 300.0, 60.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let click = |s: &mut Session| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 110.0, 40.0), v).unwrap(); + } + }; + click(&mut s); + assert_eq!(s.doc().unwrap().selection.key, Some(b)); + assert_eq!(s.doc().unwrap().selection.objects.len(), 3, "the selection stays"); + let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + // With a key object, aligning aligns to it (the Control bar and Properties name no target). + s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (100.0, 100.0, 100.0), "the others moved to the key"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 5, "bounds": "geometric"})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (85.0, 100.0, 125.0), "5 pt apart, the key still"); + click(&mut s); + assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key again lets it go"); +} + #[test] fn selection_tool_drag_is_one_undo_step() { let mut s = session(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 32f035b4..675a97d3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -36,6 +36,9 @@ enum State { /// Shift-pressed on this selected object: released without a drag, it leaves the /// selection. deselect: Option, + /// Pressed (no modifier) on this object of a selection of several: released without a + /// drag, it becomes the key object that Align aligns to (the key again: no key). + key: Option, }, Scaling { handle: Handle, @@ -235,14 +238,14 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, began: false, deselect: None }; + self.state = State::Moving { start: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } match hit_test(cx.doc, p, cx.hit_options()) { Some(h) => { let top = h.top_object(cx.isolation); let mut out = vec![]; - let mut deselect = None; + let (mut deselect, mut key) = (None, None); if m.shift { // A Shift-click takes a selected object out of the selection when it // is released; a Shift-drag moves the selection, constrained. @@ -253,8 +256,10 @@ impl Tool for SelectionTool { } } else if !cx.selection.contains(top) { out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] }))); + } else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt { + key = Some(top); } - self.state = State::Moving { start: p, began: false, deselect }; + self.state = State::Moving { start: p, began: false, deselect, key }; out } None => { @@ -276,7 +281,7 @@ impl Tool for SelectionTool { if let Some(snap) = &self.moving { (d, self.guides) = snap.snap(cx, start, d); } - self.state = State::Moving { start, began: true, deselect: None }; + 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))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -323,15 +328,18 @@ impl Tool for SelectionTool { self.state = State::Idle; b.finish() } - (PointerKind::Up, State::Moving { began, deselect, .. }) => { + (PointerKind::Up, State::Moving { began, deselect, key, .. }) => { self.state = State::Idle; self.measure = None; self.guides.clear(); self.moving = None; - match (began, deselect) { - (true, _) => vec![Action::Commit], - (false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (false, None) => vec![], + match (began, deselect, key) { + (true, ..) => vec![Action::Commit], + (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + // A click on the key object again: no key. + (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], + (false, None, Some(id)) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))], + (false, None, None) => vec![], } } (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { @@ -540,6 +548,37 @@ mod tests { assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); } + /// #541: a click on one object of a selection of several makes it the key object; a click on + /// the key again lets it go. A drag moves them all, a lone object has no key. + #[test] + fn a_click_on_a_selected_object_makes_it_the_key() { + let (d, id) = doc_with_area_type(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let p = paint(); + let click = |s: &Selection, x, y| { + let c = cx(&d, s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, x, y)) + }; + let mut s = Selection::default(); + s.add(rect); + s.add(id); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); + s.key = Some(rect); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); + // A drag moves the selection and leaves the key alone. + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]); + // One object selected: a click on it does nothing. + let mut one = Selection::default(); + one.add(rect); + assert!(click(&one, 150.0, 150.0).is_empty()); + } + #[test] fn marquee_selects() { let (d, id) = doc_with_rect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5d6047a..4937c4c9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1041,6 +1041,9 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The width of the key object's outline (Align to Key Object), thicker than the selection's. +const KEY_OUTLINE: f32 = 2.5; + /// The most anchor points the selection's outlines and anchors, and the hover highlight, are drawn /// for: an Image Trace of a photo selects hundreds of thousands of paths at once, whose outlines /// would make a mesh larger than the GPU takes in one buffer (#525). @@ -1391,6 +1394,18 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); } + // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has + // too many paths to outline. + if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) { + let outline = if too_many_anchors([n]) { + n.geometric_bounds().map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()) + } else { + Some(node_outline(n)) + }; + if let Some(bp) = outline { + stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); + } + } for id in st.selection.objects.iter().filter(|_| !big) { let Some(n) = st.doc.node(*id) else { continue }; let color = c32(st.doc.layer_color(*id)); @@ -2077,6 +2092,23 @@ mod tests { assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares"); } + /// #541: the key object's outline is drawn thicker than the rest of the selection's. + #[test] + fn the_key_object_has_a_thicker_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let thick = + |app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count(); + assert_eq!(thick(&mut app), 0, "no key yet"); + app.session.execute("select.key", &json!({"id": b})).unwrap(); + assert!(thick(&mut app) > 0, "the key's outline"); + } + /// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { let (shapes, mut delta) = frame_output(app, ctx); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7c758ac0..a2fc2160 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3117,6 +3117,7 @@ axis Both Ambos @msg Release Guides: no guides Soltar guías: no hay guías @msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo @msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado +@msg select two or more objects to distribute selecciona dos o más objetos para distribuir @msg select paths or anchor points selecciona trazados o puntos de ancla @msg SVG parse error: {0} error al analizar el SVG: {0} @msg Save for Web: the image is empty Guardar para web: la imagen está vacía diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 72e17bee..8ea33549 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3117,6 +3117,7 @@ axis Both Entrambi @msg Release Guides: no guides Rilascia guide: nessuna guida @msg Release: no layer to put the rectangle in Rilascia: nessun livello in cui inserire il rettangolo @msg Replace Spine: select a blend and a path Sostituisci dorsale: seleziona una fusione e un tracciato +@msg select two or more objects to distribute seleziona due o più oggetti da distribuire @msg select paths or anchor points seleziona tracciati o punti di ancoraggio @msg SVG parse error: {0} Errore di analisi SVG: {0} @msg Save for Web: the image is empty Salva per il web: l'immagine è vuota diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index a469412f..5aa6619a 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -52,9 +52,20 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [ ("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"), ]; +/// Whether the selection has a key object. +fn has_key(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.key.is_some()) +} + +/// What Align aligns to: the key object while there is one (a click on an object of the +/// selection makes it the key), else the Align To choice. +fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo { + if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); - let to: AlignTo = pstate(ui.ctx(), "align-to"); + let to = align_to(app, ui.ctx()); widgets::subheader(ui, tl!("Align Objects:")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; @@ -116,6 +127,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ] { if widgets::icon_button(ui, icon, tl!(tip), to == v, 28.0).clicked() { set_pstate(ui.ctx(), "align-to", v); + // Aligning to the selection or the artboard lets go of the key object. + if v != AlignTo::Key && has_key(app) { + app.run("select.key", json!({})).ok(); + } } } }); @@ -132,8 +147,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use Preview Bounds"), true, pb) { super::transform::set_pref(app, "usePreviewBounds", !pb); } - let to: AlignTo = pstate(ui.ctx(), "align-to"); - if menu_item(ui, tl!("Cancel Key Object"), to == AlignTo::Key, false) { + if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) { set_pstate(ui.ctx(), "align-to", AlignTo::Selection); app.run("select.key", json!({})).ok(); } @@ -152,4 +166,21 @@ mod tests { assert_eq!(p["to"], "selection"); assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); } + + /// #541: while the selection has a key object Align aligns to it, whatever Align To says; + /// choosing Align to Selection lets the key go. + #[test] + fn a_key_object_makes_align_align_to_it() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(align_to(&app, &ctx), AlignTo::Selection); + app.run("select.key", json!({"id": b})).unwrap(); + assert_eq!(align_to(&app, &ctx), AlignTo::Key); + app.run("select.key", json!({})).unwrap(); + assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again"); + } }