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,