Merge pull request #3124 from nrq6993-a11y/stream/vc-feat1-g4-snap-to-glyph

G4: View › Snap to Glyph snaps new type to live-text baselines and glyph bounds
This commit is contained in:
BFlat
2026-10-20 02:35:22 +07:00
committed by GitHub
7 changed files with 107 additions and 9 deletions
+4
View File
@@ -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(),
+60 -7
View File
@@ -14,7 +14,8 @@ use std::sync::Arc;
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection};
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 vectorcraft_text::{FontDb, layout as layout_text};
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<Rect>) -> Self {
pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option<Rect>, 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<Rect>) {
let skip: HashSet<NodeId> = 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<NodeId>,
screen: Option<ScreenFrame>,
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:?}");
+3
View File
@@ -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,
+31 -1
View File
@@ -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,35 @@ fn hebrew_typing_and_visual_arrows_keep_logical_text() {
assert_eq!(t.plain_text(), "שלום");
}
/// View › Snap to Glyph: new point type placed near live text snaps to its baseline.
#[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]
+1
View File
@@ -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),
+4 -1
View File
@@ -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<R
None => 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<bool> {
"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<Item>)> {
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"),
+4
View File
@@ -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,