From 0c62d9c02c4cc722591108f690a7444ea8b0ba1e Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 17:34:37 +0300 Subject: [PATCH] =?UTF-8?q?G4:=20View=20=E2=80=BA=20Snap=20to=20Glyph=20sn?= =?UTF-8?q?aps=20new=20type=20to=20live-text=20guides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the View menu toggle and add glyph baselines and bounds to drawing snap targets so point type placement lines up with existing text when Smart Guides are on. Co-authored-by: Cursor --- crates/engine/src/tooling.rs | 4 ++ crates/tools/src/guides.rs | 67 ++++++++++++++++++++++++++++++---- crates/tools/src/lib.rs | 3 ++ crates/tools/src/text_tests.rs | 33 ++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 5 ++- crates/ui-egui/src/state.rs | 4 ++ 7 files changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9975707d2..1a6809688 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -21,6 +21,8 @@ pub struct ViewInfo { pub snap_to_pixel: bool, /// View → Snap to Point: picked points (a transform's reference point) land on anchors. pub snap_to_point: bool, + /// View → Snap to Glyph: drawing snaps to live-text baselines and glyph bounds (needs Smart Guides). + pub snap_to_glyph: bool, /// View → Show Corner Widget. pub corner_widgets: bool, /// The document window (none headless): screen-fixed widgets sit in it. @@ -40,6 +42,7 @@ impl Default for ViewInfo { show_bbox: true, snap_to_pixel: false, snap_to_point: true, + snap_to_glyph: false, corner_widgets: true, screen: None, artboard: 0, @@ -202,6 +205,7 @@ impl Session { // wins instead of pixels quietly rounding every drag to whole points (#1816). snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid, snap_to_point: view.snap_to_point, + snap_to_glyph: view.snap_to_glyph && view.smart_guides && !view.snap_to_grid, corner_widgets: view.corner_widgets, fill_active: self.fill_active, gradient_stop: self.selected_stop(), diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index b3ad22a16..6c1ddbe80 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -13,8 +13,9 @@ use std::sync::Arc; use vectorcraft_doc::hit::{HitOptions, hit_test}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection}; +use vectorcraft_text::{FontDb, layout as layout_text}; use vectorcraft_geom::kurbo::ParamCurveNearest; -use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2, constrain_angle_from}; +use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, Shape, SubPath, Vec2, constrain_angle_from}; use crate::bbox::Handle; use crate::{Overlay, ScreenFrame, ToolContext}; @@ -53,6 +54,7 @@ enum Kind { Artboard, Bleed, Guide, + Glyph, } impl Kind { @@ -64,6 +66,7 @@ impl Kind { Kind::Artboard => "artboard", Kind::Bleed => "bleed", Kind::Guide => "guide", + Kind::Glyph => "glyph", } } } @@ -219,9 +222,12 @@ impl Targets { /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its /// anchors, centres, edges and path segments, its anchors in line too. The anchors and /// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets. - pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option) -> Self { + pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option, glyphs: bool) -> Self { let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing); t.push_paths(doc, exclude, true); + if glyphs { + t.push_glyphs(doc, exclude, visible); + } t.index() } @@ -265,6 +271,51 @@ impl Targets { self.ys.push((a.y, a, Kind::Anchor)); } + /// Live-text baselines and glyph bounds (View › Snap to Glyph). + fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { + let skip: HashSet = exclude.iter().copied().collect(); + let db = FontDb::global(); + let kind = Kind::Glyph; + let mut budget = BUDGET / 4; + walk_but(&doc.layers, &skip, &mut |n| { + if budget == 0 || !n.visible { + return; + } + let NodeKind::Text(t) = &n.kind else { return }; + let lay = layout_text(db, t); + let ink = t.xf.transform_rect_bbox(lay.bounds); + if !ink.is_finite() || ink.width() <= 0.0 || ink.height() <= 0.0 { + return; + } + if let Some(v) = visible + && ink.intersect(v).area() <= 0.0 + && !v.contains(ink.center()) + { + return; + } + let to_doc = |p: Point| t.xf * p; + for line in &lay.lines { + let at = to_doc(Point::new(line.x0, line.baseline)); + self.ys.push((at.y, at, kind)); + } + for g in &lay.glyphs { + if budget == 0 { + break; + } + let bb = g.outline.bounding_box(); + if !(bb.width() > 0.0 && bb.height() > 0.0) { + continue; + } + let r = t.xf.transform_rect_bbox(bb); + for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { + self.xs.push((p.x, p, kind)); + self.ys.push((p.y, p, kind)); + } + budget -= 1; + } + }); + } + /// The same targets drawing their guides as the Smart Guides preferences in `cx` ask /// ([`GuideStyle::of`]). pub fn styled(mut self, cx: &ToolContext) -> Self { @@ -1273,6 +1324,7 @@ struct SnapKey { exclude: Vec, screen: Option, view: [bool; 5], + snap_to_glyph: bool, style: GuideStyle, tolerances: [f64; 2], } @@ -1284,6 +1336,7 @@ impl SnapKey { exclude: exclude.to_vec(), screen: cx.screen, view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides], + snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], } @@ -1341,7 +1394,7 @@ impl DrawSnap { } fn gather(&mut self, cx: &ToolContext, key: SnapKey) { - let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx))); + let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx), key.snap_to_glyph)); self.targets = Some((Some(key), snap)); } @@ -1650,7 +1703,7 @@ mod tests { || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) }; assert!(!gives_corner(&Targets::collect(&d, &[], None))); - assert!(!gives_corner(&Targets::for_drawing(&d, &[], None))); + assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); assert!(!gives_corner(&Targets::for_handle(&d, face))); assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); @@ -1742,7 +1795,7 @@ mod tests { let (d, _) = doc_with_rect(); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); - let t = Targets::for_drawing(&d, &[], None).styled(&c); + let t = Targets::for_drawing(&d, &[], None, false).styled(&c); // The rect is 100..200: its corner, a point on its side, and in line with an anchor. let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); @@ -1796,7 +1849,7 @@ mod tests { d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; - let t = Targets::for_drawing(&d, &[], None).styled(&c).with_rulers(&c); + let t = Targets::for_drawing(&d, &[], None, false).styled(&c).with_rulers(&c); let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0); assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}"); assert_eq!(labels(&ov), ["intersect"]); @@ -1843,7 +1896,7 @@ mod tests { let mut s = Selection::default(); s.set([id]); s.anchors.insert(id, [(0, 1)].into_iter().collect()); - for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None), Targets::for_anchor_drag(&d, &s)] { + for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2eaceb1ba..81e6f6a54 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -252,6 +252,8 @@ pub struct ToolContext<'a> { /// points (a transform's reference point) land on anchors and ruler guides within /// [`Self::snap_tolerance`]. pub snap_to_point: bool, + /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). + pub snap_to_glyph: bool, /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. pub corner_widgets: bool, /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. @@ -934,6 +936,7 @@ pub(crate) mod testutil { show_bbox: true, snap_to_pixel: false, snap_to_point: true, + snap_to_glyph: false, corner_widgets: true, fill_active: true, gradient_stop: None, diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index efcbd5da8..a8fc0bf8e 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -3,7 +3,8 @@ use super::*; use crate::testutil::{cx, paint}; -use vectorcraft_doc::{CharStyle, Document, Node, Selection}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject}; +use vectorcraft_text::FontDb; fn doc_with_text(text: &str) -> (Document, NodeId) { let mut d = Document::new(500.0, 500.0); @@ -508,6 +509,36 @@ fn hebrew_typing_and_visual_arrows_keep_logical_text() { assert_eq!(t.plain_text(), "שלום"); } +/// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its +/// centre, and hovering shows it first; an area's corners land on its anchors. +#[test] +fn snap_to_glyph_lines_up_new_point_type_with_a_baseline() { + let (d, _) = doc_with_text("Baseline"); + let (sel, p) = (Selection::default(), paint()); + let mut c = cx(&d, &sel, &p); + c.snap_to_glyph = true; + let existing = match &d.layers[0].children().unwrap()[0].kind { + NodeKind::Text(t) => t, + _ => panic!("text"), + }; + let lay = vectorcraft_text::layout(FontDb::global(), existing); + let baseline_y = (existing.xf * Point::new(0.0, lay.lines[0].baseline)).y; + let mut tool = TypeTool::new("type"); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 250.0, baseline_y + 2.0)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, baseline_y + 2.0)); + assert!( + acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && (v["y"].as_f64().unwrap() - baseline_y).abs() < 0.5)), + "{acts:?}" + ); + c.snap_to_glyph = false; + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 250.0, baseline_y + 2.0)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, baseline_y + 2.0)); + assert!( + acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && (v["y"].as_f64().unwrap() - (baseline_y + 2.0)).abs() < 0.5)), + "{acts:?}" + ); +} + /// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its /// centre, and hovering shows it first; an area's corners land on its anchors. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 768210b43..e78c121fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -681,6 +681,7 @@ impl VectorcraftApp { // nor the Live Corners widgets, and the tools do not answer the pointer on them. show_bbox: self.ui.view.bounding_box && self.ui.view.edges, snap_to_point: self.ui.view.snap_to_point, + snap_to_glyph: self.ui.view.snap_to_glyph, corner_widgets: self.ui.view.corner_widgets && self.ui.view.edges, screen: self.screen_frame(), artboard: self.view().map_or(0, |v| v.artboard), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 19d02a806..ef0138449 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -192,6 +192,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), ( "view.preciseCursors", "Precise Cursors", @@ -1205,6 +1206,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("no such saved selection".into()), }, "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), + "view.snapToGlyph" => flag(&mut app.ui.view.snap_to_glyph), // The preference itself (General › Use Precise Cursors), so the command, the dialog and the saved // preferences share one value. "view.preciseCursors" => { @@ -1777,6 +1779,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, "view.snapToPixel" => v.snap_to_pixel, + "view.snapToGlyph" => v.snap_to_glyph, // Type on a Path effect of the selected path type. "type.pathOptions" if p.get("effect").is_some() => { selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) @@ -2912,7 +2915,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Snap to Grid", "view.snapToGrid"), c("Snap to Pixel", "view.snapToPixel"), c("Snap to Point", "view.snapToPoint"), - todo("Snap to Glyph"), + c("Snap to Glyph", "view.snapToGlyph"), Sep, c("New View…", "view.saved.new"), c("Edit Views…", "view.saved.edit"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e52d18eea..d6e5a179e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -224,6 +224,9 @@ pub struct ViewFlags { pub corner_widgets: bool, /// View → Snap to Pixel. pub snap_to_pixel: bool, + /// View → Snap to Glyph. + #[serde(default)] + pub snap_to_glyph: bool, /// View → Show/Hide Text Threads. pub text_threads: bool, /// View → Show/Hide Gradient Annotator. @@ -252,6 +255,7 @@ impl Default for ViewFlags { trim_view: false, corner_widgets: true, snap_to_pixel: false, + snap_to_glyph: false, text_threads: true, gradient_annotator: true, hidden_chars: false,