mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
G4: View › Snap to Glyph snaps new type to live-text guides
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 <cursoragent@cursor.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Cursor
parent
cb229ac08c
commit
0c62d9c02c
@@ -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(),
|
||||
|
||||
@@ -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<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:?}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user