From beea693ee132b740400466392f64a1d0085bf785 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:52:03 +0000 Subject: [PATCH 1/2] Part of #394: Type Object Selection by Path Only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › Type › Type Object Selection by Path Only was stored and never read. On, the Selection tools pick type on its type path only — point type's baseline (the line the selection marks under it, across its layout's width), area type's frame, type on a path's path — within the selection tolerance; a click among the glyphs or inside the frame selects nothing. Off (the default), anywhere in the type's bounds selects it, as before. `HitOptions` gains `type_path_only`, set by the tools' context (`hit_options()`) and the canvas's `hit_at` from the preference, so the Selection tools, Cmd-click behind, object highlighting and the context menu agree. Tests at the hit-test level (point, area and on-path type) and through the Selection tool with `prefs.set`. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 6 +-- crates/doc/src/hit.rs | 64 +++++++++++++++++++++++++++++++- crates/engine/src/tests_live.rs | 7 +++- crates/engine/src/tests_prefs.rs | 32 ++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 11 +++++- crates/tools/src/text.rs | 2 +- crates/ui-egui/src/canvas.rs | 4 +- docs/mcp.md | 4 ++ 9 files changed, 122 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a608bc65..28d489cc 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 38 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…). + - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on its baseline, frame or path, no longer anywhere in its bounds). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 37 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. @@ -215,7 +215,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 38 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Show Pixel Grid, performance, devices…) | 14–22 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 37 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Show Pixel Grid, performance, devices…) | 14–22 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 58c26a87..29cde418 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -50,11 +50,15 @@ pub struct HitOptions { pub outline: bool, /// "Object Selection by Path Only" preference. pub path_only: bool, + /// "Type Object Selection by Path Only" preference: type is picked on its type path only + /// (point type's baseline, area type's frame, type on a path's path), not anywhere in its + /// bounds. + pub type_path_only: bool, } impl Default for HitOptions { fn default() -> Self { - Self { tol: 3.0, outline: false, path_only: false } + Self { tol: 3.0, outline: false, path_only: false, type_path_only: false } } } @@ -190,6 +194,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } (!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) } + NodeKind::Text(t) if opt.type_path_only => stroke_contains(&type_path(t), 0.0, opt.tol, p).then_some(HitKind::Outline), NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } @@ -205,6 +210,24 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } } +/// The type path of `t` in document space, what "Type Object Selection by Path Only" picks type +/// by: area type's frame, type on a path's path, and for point type its first baseline (the line +/// the selection marks under it), across the width of its layout. +fn type_path(t: &crate::TextObject) -> vectorcraft_geom::BezPath { + use crate::TextKind; + match &t.kind { + TextKind::Area { frame } => frame.transformed(t.xf).to_bezpath(), + TextKind::OnPath { path, .. } => path.transformed(t.xf).to_bezpath(), + TextKind::Point => { + let b = t.cached_bounds.unwrap_or_else(|| t.estimate_bounds()); + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to(t.xf * Point::new(b.x0, 0.0)); + bp.line_to(t.xf * Point::new(b.x1, 0.0)); + bp + } + } +} + /// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option { match &n.kind { @@ -404,6 +427,45 @@ mod tests { } } + /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its + /// frame and type on a path on its path, no longer anywhere in its bounds. + #[test] + fn type_path_only_picks_type_on_its_path() { + use crate::{CharStyle, TextKind, TextObject}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject| { + let id = d.alloc_id(); + d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + id + }; + // Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide. + let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() }); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let point = add(point); + let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + let area = add(area); + let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default()); + on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None }; + let on_path = add(on_path); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind)); + // Off: anywhere in the bounds. + assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds))); + assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds))); + assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds))); + // On: the glyphs and the inside of the frame or path no longer pick the type. + assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs"); + assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame"); + assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path"); + // The baseline (within the tolerance, across the layout's width), frame and path do. + assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline"); + assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout"); + assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame"); + assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); + } + /// `objects_at`: every object under the point, topmost first; a group counts once. #[test] fn objects_at_lists_the_stack() { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 204afa4d..67c040c4 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -589,8 +589,11 @@ fn mesh_hit_test_inside() { sel(&mut s, &[a]); s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); let d = &s.doc().unwrap().doc; - let h = - vectorcraft_doc::hit::hit_test(d, Point::new(50.0, 30.0), vectorcraft_doc::hit::HitOptions { tol: 1.0, outline: false, path_only: false }); + let h = vectorcraft_doc::hit::hit_test( + d, + Point::new(50.0, 30.0), + vectorcraft_doc::hit::HitOptions { tol: 1.0, outline: false, path_only: false, type_path_only: false }, + ); assert_eq!(h.map(|h| h.leaf), Some(a)); } diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 4ebe5e0e..6e0cc7c7 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -561,3 +561,35 @@ fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() { s.execute("text.setStyle", &json!({"font": to})).unwrap(); assert_eq!(fonts(&s), [to, to, to]); } + +// ---------- #394, fourth batch ---------- + +/// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no +/// longer selects it with the Selection tool, a click on its baseline does; off (the default), +/// anywhere in its bounds selects it. +#[test] +fn type_selection_by_path_only_picks_type_on_its_baseline() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = new_doc(); + let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.node(id).and_then(|n| n.geometric_bounds()).unwrap(); + assert!(b.y0 < 95.0 && b.x1 > 130.0, "the glyphs rise above the baseline at y = 100: {b:?}"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| { + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), ViewInfo::default()).unwrap(); + } + s.doc().unwrap().selection.objects.clone() + }; + // Clear of the bounding box's handles: 15 px in from the left, halfway up the glyphs. + let (x, glyphs) = (b.x0 + 15.0, (b.y0 + 100.0) / 2.0); + assert_eq!(click(&mut s, x, glyphs), vec![id], "off: a click among the glyphs selects the type"); + assert!(click(&mut s, x, 300.0).is_empty(), "empty canvas deselects"); + set_pref(&mut s, "typeSelectionByPathOnly", json!(true)); + assert!(click(&mut s, x, glyphs).is_empty(), "on: a click among the glyphs selects nothing"); + assert_eq!(click(&mut s, x, 101.0), vec![id], "on: a click on the baseline selects it"); + assert!(click(&mut s, x, 300.0).is_empty()); + set_pref(&mut s, "typeSelectionByPathOnly", json!(false)); + assert_eq!(click(&mut s, x, glyphs), vec![id], "off again: the glyphs select it"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index dbc1d484..b149c31a 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -166,6 +166,7 @@ impl Session { auto_add_delete: !self.prefs.disable_auto_add_delete, selection_tolerance: self.prefs.selection_tolerance, path_only: self.prefs.object_selection_by_path_only, + type_path_only: self.prefs.type_selection_by_path_only, double_click_isolate: self.prefs.double_click_to_isolate, select_behind: self.prefs.ctrl_click_selects_behind, highlight_anchors: self.prefs.highlight_anchors_on_hover, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ddadd8ee..b143e8a4 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -273,6 +273,9 @@ pub struct ToolContext<'a> { /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path /// doesn't pick it, only one on its path does. pub path_only: bool, + /// Type → Type Object Selection by Path Only: type is picked on its type path only (point + /// type's baseline, area type's frame, type on a path's path), not anywhere in its bounds. + pub type_path_only: bool, /// General → Double Click To Isolate: a double-click on a group with the Selection tool /// isolates it. pub double_click_isolate: bool, @@ -352,7 +355,12 @@ impl ToolContext<'_> { self.tol(self.selection_tolerance) } pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { - vectorcraft_doc::hit::HitOptions { tol: self.pick_tol(), outline: self.outline, path_only: self.path_only } + vectorcraft_doc::hit::HitOptions { + tol: self.pick_tol(), + outline: self.outline, + path_only: self.path_only, + type_path_only: self.type_path_only, + } } } @@ -650,6 +658,7 @@ pub(crate) mod testutil { auto_add_delete: true, selection_tolerance: 3.0, path_only: false, + type_path_only: false, double_click_isolate: true, select_behind: true, highlight_anchors: true, diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f6e8d62a..ac986f48 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -230,7 +230,7 @@ impl TypeTool { /// Path under `p` for the Area Type / Type on a Path tools. fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option { - let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, path_only: false })?; + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, path_only: false, type_path_only: false })?; match &cx.doc.node(h.leaf)?.kind { NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf), _ => None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index af203589..7a045ef2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1043,13 +1043,15 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } /// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it -/// (Selection & Anchor Display › Tolerance and Object Selection by Path Only). +/// (Selection & Anchor Display › Tolerance and Object Selection by Path Only, Type › Type Object +/// Selection by Path Only). fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { let prefs = &app.session.prefs; let opt = vectorcraft_doc::hit::HitOptions { tol: prefs.selection_tolerance / zoom, outline: app.ui.view.outline, path_only: prefs.object_selection_by_path_only, + type_path_only: prefs.type_selection_by_path_only, }; vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt) } diff --git a/docs/mcp.md b/docs/mcp.md index 27d0defd..cdeb1ea2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1211,6 +1211,10 @@ Not read yet: Construction Guides and their Angles, and Spacing Guides (the tool selection (←/→), leading (↑/↓) and baseline shift (Shift+↑/↓), five steps with Cmd/Ctrl too. - `missingGlyphProtection` (on by default): `text.setStyle` and `text.setRangeStyle` changing the font leave the characters the new font has no glyph for in the font that had one. +- `typeSelectionByPathOnly` (off by default): on, the Selection tools (`pointer_gesture` with `selection`, and the + mouse) pick type on its type path only — point type's baseline, area type's frame, type on a path's path — within + `selectionTolerance`; a click among the glyphs or inside the frame selects nothing. Off, anywhere in the type's + bounds selects it. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}} From afb438367df90ae7d4eddb6205aaffed1f4ea48c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:19:24 +0700 Subject: [PATCH 2/2] Type Object Selection by Path Only: every baseline, Type tool and Eyedropper (review of #475) - Type is picked on the baseline of any of its lines (point type and area type, besides area type's frame), as in Illustrator, not only the first one: the layout's baselines are cached with its bounds (`TextObject::cached_baselines`, from `TextLayout::baselines`), with the first one across the bounds as the fallback. Vertical type's runs down each column's centre line. - Type in perspective is picked where it is drawn (through its projection). - The bounds rule type out before its path is built, so hovering stays free of allocations away from type. - The Type tools still edit type clicked among its characters (they made new type there with the preference on), and the Eyedropper still samples it there, as it does inside fills with Object Selection by Path Only. - Tests for all of it; docs/mcp.md and ROADMAP say so. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/blend.rs | 1 + crates/doc/src/hit.rs | 95 +++++++++++++++++---- crates/doc/src/text.rs | 14 +++ crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/place/text.rs | 3 +- crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/typecmd.rs | 3 +- crates/engine/src/tests_live.rs | 6 +- crates/engine/src/tests_prefs.rs | 47 ++++++++++ crates/text/src/lib.rs | 12 +++ crates/text/src/tests_vertical.rs | 35 ++++++++ crates/tools/src/text.rs | 7 +- crates/tools/src/xform/eyedropper.rs | 4 +- docs/mcp.md | 7 +- 15 files changed, 208 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 28d489cc..a2d5fdb9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on its baseline, frame or path, no longer anywhere in its bounds). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 37 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…). + - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 37 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 3608bf5e..e41efc8a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -687,6 +687,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { } if resized { o.cached_bounds = None; + o.cached_baselines.clear(); } } diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 29cde418..9db6e3dc 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -194,7 +194,11 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } (!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) } - NodeKind::Text(t) if opt.type_path_only => stroke_contains(&type_path(t), 0.0, opt.tol, p).then_some(HitKind::Outline), + // The type path lies within the bounds: they rule out the rest before it is measured. + NodeKind::Text(t) if opt.type_path_only => { + n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?; + on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) + } NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } @@ -210,22 +214,26 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } } -/// The type path of `t` in document space, what "Type Object Selection by Path Only" picks type -/// by: area type's frame, type on a path's path, and for point type its first baseline (the line -/// the selection marks under it), across the width of its layout. -fn type_path(t: &crate::TextObject) -> vectorcraft_geom::BezPath { +/// Is `p` within `tol` of the type path of `t` (in `n`), what "Type Object Selection by Path +/// Only" picks type by: its baselines ([`crate::TextObject::baselines`]), area type's frame and +/// type on a path's path, drawn through `n`'s perspective projection if it has one. +fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool { use crate::TextKind; - match &t.kind { - TextKind::Area { frame } => frame.transformed(t.xf).to_bezpath(), - TextKind::OnPath { path, .. } => path.transformed(t.xf).to_bezpath(), - TextKind::Point => { - let b = t.cached_bounds.unwrap_or_else(|| t.estimate_bounds()); - let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to(t.xf * Point::new(b.x0, 0.0)); - bp.line_to(t.xf * Point::new(b.x1, 0.0)); - bp - } + use vectorcraft_geom::kurbo::ParamCurveNearest; + let h = n.projection(); + let project = |q: Point| h.as_ref().and_then(|h| h.apply(q)).unwrap_or(q); + // A homography keeps straight lines straight: a baseline's ends are enough. + let near = |(a, b): (Point, Point)| vectorcraft_geom::Line::new(project(t.xf * a), project(t.xf * b)).nearest(p, 1e-9).distance_sq <= tol * tol; + if t.baselines().any(near) { + return true; } + let (TextKind::Area { frame: path } | TextKind::OnPath { path, .. }) = &t.kind else { return false }; + let mut path = path.transformed(t.xf); + if h.is_some() { + let piece = path.bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); + path = crate::live::map_nonlinear(&path, piece, project); + } + stroke_contains(&path.to_bezpath(), 0.0, tol, p) } /// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). @@ -466,6 +474,63 @@ mod tests { assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path"); } + /// Type Object Selection by Path Only with the layout's baselines: every line's for point type, + /// area type's besides its frame, a vertical column's centre line, and type in perspective + /// where it is drawn. + #[test] + fn type_path_only_picks_every_baseline() { + use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject}; + use vectorcraft_geom::{Affine, Homography}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let mut add = |t: TextObject, perspective: Option| { + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + n.perspective = perspective.map(|a| { + Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) }) + }); + let id = n.id; + d.insert(Some(l), 9, n).unwrap(); + id + }; + let style = CharStyle { size: 20.0, ..CharStyle::default() }; + // Two lines of point type at (10, 100), 24 pt apart. + let mut point = TextObject::point( + Point::new(10.0, 100.0), + "Hello +world", + style.clone(), + ); + point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0)); + point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))]; + let point = add(point, None); + // Area type: a line inside its frame. + let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))]; + let area = add(area, None); + // Vertical point type at (300, 200) with no baselines cached: down its first column. + let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone()); + vertical.vertical = true; + vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0)); + let vertical = add(vertical, None); + // Point type drawn 100 to the right of where it is laid out. + let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style); + moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0)); + let moved = add(moved, Some(Affine::translate((100.0, 0.0)))); + let path_only = HitOptions { type_path_only: true, ..Default::default() }; + let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf); + assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline"); + assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's"); + assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines"); + assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line"); + assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline"); + assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines"); + assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column"); + assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it"); + assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn"); + assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out"); + } + /// `objects_at`: every object under the point, topmost first; a group counts once. #[test] fn objects_at_lists_the_stack() { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 913fc7fd..fb0d246e 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -715,6 +715,10 @@ pub struct TextObject { /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, + /// Cached baselines in text space, start to end, one per line that holds characters (a + /// vertical column's centre line), filled in with `cached_bounds` (not serialized). + #[serde(skip)] + pub cached_baselines: Vec<(Point, Point)>, } impl TextObject { @@ -731,6 +735,7 @@ impl TextObject { path_spacing: 0.0, wrap: Vec::new(), cached_bounds: None, + cached_baselines: Vec::new(), } } pub fn plain_text(&self) -> String { @@ -839,6 +844,15 @@ impl TextObject { pub fn local_bounds(&self) -> Rect { self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) } + /// The baselines in text space (the layout cache): for point type with none cached, the first + /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). + pub fn baselines(&self) -> impl Iterator + '_ { + let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { + let b = self.local_bounds(); + if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } + }); + self.cached_baselines.iter().copied().chain(first) + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 4ea554a8..7fdedc4a 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -300,7 +300,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the text is empty")); } let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null)); - t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + crate::cmd::typecmd::refresh_bounds(&mut t); let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; Ok(json!({ "count": count })) diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 26be4029..049c9c73 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -197,8 +197,7 @@ pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObjec run.text = text; } t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) }; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(lay.bounds); + crate::cmd::typecmd::refresh_bounds(&mut t); t } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 491f6142..6bc50072 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -469,6 +469,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_spacing: 0.0, wrap: Vec::new(), cached_bounds: None, + cached_baselines: Vec::new(), }; if bool_or(p, "placeholder", false) { super::typemenu::fill_with_placeholder(&mut t); @@ -601,6 +602,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_spacing: 0.0, wrap: Vec::new(), cached_bounds: None, + cached_baselines: Vec::new(), }; for r in &mut h.runs { r.style.tracking = tr; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 1321bddf..4cd3b4e8 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -97,10 +97,11 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) } -/// Recompute the layout bounds cache after a text edit. +/// Recompute the layout caches (bounds and baselines) after a text edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); + t.cached_baselines = lay.baselines(); } /// Text objects among `ids` and their descendants. diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 67c040c4..a26495d2 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -589,11 +589,7 @@ fn mesh_hit_test_inside() { sel(&mut s, &[a]); s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); let d = &s.doc().unwrap().doc; - let h = vectorcraft_doc::hit::hit_test( - d, - Point::new(50.0, 30.0), - vectorcraft_doc::hit::HitOptions { tol: 1.0, outline: false, path_only: false, type_path_only: false }, - ); + let h = vectorcraft_doc::hit::hit_test(d, Point::new(50.0, 30.0), vectorcraft_doc::hit::HitOptions { tol: 1.0, ..Default::default() }); assert_eq!(h.map(|h| h.leaf), Some(a)); } diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 6e0cc7c7..71fc6bc3 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -593,3 +593,50 @@ fn type_selection_by_path_only_picks_type_on_its_baseline() { set_pref(&mut s, "typeSelectionByPathOnly", json!(false)); assert_eq!(click(&mut s, x, glyphs), vec![id], "off again: the glyphs select it"); } + +/// Type Object Selection by Path Only (#394) with real layout: every line's baseline picks point +/// type, between the lines nothing does; the Type tool still edits type clicked among its +/// characters, and the Eyedropper still samples it there. +#[test] +fn type_selection_by_path_only_takes_every_line_and_spares_the_type_tool_and_eyedropper() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let v = ViewInfo::default(); + let mut s = new_doc(); + let created = s + .execute( + "text.create", + &json!({"x": 100, "y": 100, "text": "Hello +world", "size": 20}), + ) + .unwrap(); + let src = NodeId(created["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(src).unwrap().kind else { panic!("not type") }; + let [(a, _), (b, _)] = t.cached_baselines[..] else { panic!("two baselines: {:?}", t.cached_baselines) }; + let (first, second) = (t.xf * a, t.xf * b); + assert!(first.y == 100.0 && second.y > 115.0, "{first:?} {second:?}"); + let click = |s: &mut Session, tool: &str, x: f64, y: f64| { + s.select_tool(tool, v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + s.doc().unwrap().selection.objects.clone() + }; + set_pref(&mut s, "typeSelectionByPathOnly", json!(true)); + let x = first.x + 15.0; + assert!(click(&mut s, "selection", x, 300.0).is_empty()); + assert_eq!(click(&mut s, "selection", x, second.y + 1.0), vec![src], "the second line's baseline"); + assert!(click(&mut s, "selection", x, (first.y + second.y) / 2.0).is_empty(), "between the lines"); + // The Type tool: a click among the glyphs puts the caret in that type, it adds none. + assert_eq!(click(&mut s, "type", x, first.y - 7.0), vec![src]); + assert!(s.tool_wants_text(), "editing"); + let texts = + |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).count()); + assert_eq!(texts(&s), 1, "no new type"); + // The Eyedropper: a click among the glyphs samples the type into the selected type. + s.select_tool("selection", v).unwrap(); + let dst = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Target"})).unwrap()["id"].as_u64().unwrap()); + click(&mut s, "eyedropper", x, first.y - 7.0); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") }; + assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into()))); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ea066f82..7833601a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -158,6 +158,18 @@ impl TextLayout { pub fn physical_point(&self, p: Point) -> Point { if self.vertical { self.line_xf * p } else { p } } + /// Each line's baseline in text space, start to end (a vertical column's centre line), for + /// the lines that hold characters; none for type on a path, which its path stands for. + pub fn baselines(&self) -> Vec<(Point, Point)> { + if self.on_path { + return vec![]; + } + let line = |l: &LineInfo| { + let y = if self.vertical { l.baseline + (l.descent - l.ascent) / 2.0 } else { l.baseline }; + (self.physical_point(Point::new(l.x0, y)), self.physical_point(Point::new(l.x1, y))) + }; + self.lines.iter().filter(|l| l.x1 > l.x0).map(line).collect() + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests_vertical.rs index cc72bbd3..f5a18f7f 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests_vertical.rs @@ -73,6 +73,41 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() { assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame"); } +/// `baselines`: one per line that holds characters, along it for horizontal type and down the +/// column's centre line for vertical type (where point type's anchor is); none on a path. +#[test] +fn baselines_run_along_lines_and_down_column_centres() { + let h = layout( + FontDb::global(), + &TextObject::point( + Point::ZERO, + "§§ + +§", + CharStyle { size: 20.0, ..CharStyle::default() }, + ), + ); + let hb = h.baselines(); + assert_eq!(hb.len(), 2, "the empty line has none: {hb:?}"); + assert!(hb[0].0 == Point::ZERO && hb[0].1.y == 0.0 && hb[0].1.x > 15.0, "the first baseline runs from the anchor: {hb:?}"); + assert!(hb[1].0.y > 40.0 && hb[1].0.y == hb[1].1.y && hb[1].1.x > hb[1].0.x, "the third line's, lower: {hb:?}"); + let v = layout( + FontDb::global(), + &vertical( + "§§ +§", + ), + ); + let vb = v.baselines(); + let (a, c) = (ink(&v, 0), ink(&v, 2)); + assert_eq!(vb.len(), 2, "{vb:?}"); + assert!(vb[0].0.x.abs() < 4.0 && vb[0].0.x == vb[0].1.x && vb[0].1.y > vb[0].0.y + 30.0, "down the first column's centre: {vb:?}"); + assert!((vb[0].0.x - a.center().x).abs() < 4.0 && (vb[1].0.x - c.center().x).abs() < 4.0, "through the glyphs: {vb:?} {a:?} {c:?}"); + let mut on_path = vertical("ab"); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None }; + assert!(layout(FontDb::global(), &on_path).baselines().is_empty()); +} + #[test] fn type_on_a_path_stays_horizontal() { let mut t = vertical("ab"); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index ac986f48..9f2ae1d9 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -230,7 +230,7 @@ impl TypeTool { /// Path under `p` for the Area Type / Type on a Path tools. fn path_at(cx: &ToolContext, p: Point, closed: bool) -> Option { - let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, path_only: false, type_path_only: false })?; + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { tol: cx.tol(4.0), outline: true, ..Default::default() })?; match &cx.doc.node(h.leaf)?.kind { NodeKind::Path { path, .. } if !closed || path.is_closed() => Some(h.leaf), _ => None, @@ -238,9 +238,10 @@ impl TypeTool { } /// Start editing the type under `p` with the caret there → the actions (ending the previous - /// edit, selecting the type); None when no type is under `p`. + /// edit, selecting the type); None when no type is under `p`. A click among the characters + /// edits them, whatever Type Object Selection by Path Only says. fn edit_at(&mut self, cx: &ToolContext, p: Point) -> Option> { - let h = hit_test(cx.doc, p, cx.hit_options())?; + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { type_path_only: false, ..cx.hit_options() })?; let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) else { return None }; let lay = self.layout(t); let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 6a0c62fa..d52b95d3 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -70,7 +70,9 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } - let opts = vectorcraft_doc::hit::HitOptions { path_only: false, ..cx.hit_options() }; + // A click inside a fill or among characters samples it too, whatever Object and Type + // Object Selection by Path Only say. + let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() }; let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] }; let src = paint_owner(cx.doc, h.leaf); let Some(n) = cx.doc.node(src) else { return vec![] }; diff --git a/docs/mcp.md b/docs/mcp.md index cdeb1ea2..75c60a50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1212,9 +1212,10 @@ Not read yet: Construction Guides and their Angles, and Spacing Guides (the tool - `missingGlyphProtection` (on by default): `text.setStyle` and `text.setRangeStyle` changing the font leave the characters the new font has no glyph for in the font that had one. - `typeSelectionByPathOnly` (off by default): on, the Selection tools (`pointer_gesture` with `selection`, and the - mouse) pick type on its type path only — point type's baseline, area type's frame, type on a path's path — within - `selectionTolerance`; a click among the glyphs or inside the frame selects nothing. Off, anywhere in the type's - bounds selects it. + mouse) pick type on its type path only — the baseline of each of its lines (a vertical column's centre line), area + type's frame, type on a path's path — within `selectionTolerance`; a click among the glyphs, between the lines or + inside the frame selects nothing. Off, anywhere in the type's bounds selects it. Marquee selection is unchanged, and + the Type tools (a click among the characters edits them) and the Eyedropper (`eyedropper`) are not affected. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}}