From 42fced4725e1f2af413b83b815a2956e21075e20 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 15:18:03 +0700 Subject: [PATCH] Smart Guides: "align" also on a handle already in line, Free Transform's move offset and slice readouts follow Measurement Labels (review of #453) - `Targets::align_rects` tells `snap_point_with` an axis lined up even when nothing moved or Alignment Guides hide the line, so the "align" label stays. - Free Transform's move offset is a measurement label (as the Selection tool's), its scale/rotate readouts Transform Tools; slice move/resize readouts follow Measurement Labels. - Snap to Point (Smart Guides off) keeps the default look for moves too, as for drawn points and guides. - Test: guides dragged out of a ruler (#451) follow Snapping Tolerance, Anchor/Path Labels and Measurement Labels; docs/mcp.md. Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/guides.rs | 21 ++++++++++++++++----- crates/tools/src/rulerguide.rs | 19 +++++++++++++++++++ crates/tools/src/slice.rs | 3 ++- crates/tools/src/xform/free.rs | 8 ++++---- docs/mcp.md | 4 ++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index c36d20db..d428ed98 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -243,7 +243,7 @@ impl Targets { /// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart /// Guides off: the anchors of the art but `exclude` and the ruler guides. pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option { - (cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx).styled(cx)) + (cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx)) } /// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds @@ -338,9 +338,9 @@ impl Targets { return self.snap_point(p, tol); } let rects: Vec = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect(); - let (adj, mut ov) = self.snap_rects(&rects, tol); + let (adj, mut ov, aligned) = self.align_rects(&rects, tol); let out = p + adj; - if adj != Vec2::ZERO { + if aligned { ov.extend(self.label(out, "align")); } (out, ov) @@ -354,6 +354,13 @@ impl Targets { /// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any /// of them nearest a target, per axis. Returns the shift and the guides. pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec) { + let (d, ov, _) = self.align_rects(rects, tol); + (d, ov) + } + + /// [`Self::snap_rects`], also saying whether an axis lined up (even with Alignment Guides + /// off, or already in line, when there is no line or shift to tell). + fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec, bool) { let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| { rects .iter() @@ -378,7 +385,7 @@ impl Targets { let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1)); ov.extend(self.line(Point::new(x0, y), Point::new(x1, y))); } - (d, ov) + (d, ov, best_x.is_some() || best_y.is_some()) } /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for @@ -646,7 +653,7 @@ mod tests { assert!( ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) ); - // Alignment Guides off: the point still lines up, no line and no "align" label. + // Alignment Guides off: the point still lines up and says so, without the line. let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; let t = Targets::collect(&d, &[], None).styled(&c); let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); @@ -655,6 +662,10 @@ mod tests { assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}"); let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); assert_eq!((dv.x, ov.len()), (-2.0, 0)); + // A handle already in line moves nowhere, yet is still "align". + let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0); + assert_eq!(q, Point::new(400.0, 200.0)); + assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}"); // Anchor/Path Labels off: on the anchor, without saying so. let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) }; let t = Targets::collect(&d, &[], None).styled(&c); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 50b02c4e..dccdeed1 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -380,6 +380,25 @@ mod tests { assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach"); } + /// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping + /// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what + /// shows by it. + #[test] + fn a_new_guide_follows_the_smart_guide_preferences() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let drag = |c: &ToolContext| { + let mut g = NewGuide::new(c, true, None); + let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true); + let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") }; + (p["pos"].as_f64().unwrap(), g.overlays(c)) + }; + let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) }); + assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}"); + let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) }); + assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said"); + } + #[test] fn a_new_guide_is_made_on_release_over_the_canvas_only() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 7db0d153..41d86438 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -282,7 +282,8 @@ impl Tool for SliceSelectionTool { o.extend(Handle::ALL.iter().map(|h| Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 })); } } - match &self.drag { + // Smart Guides › Measurement Labels: the size readouts while resizing or moving slices. + match self.drag.as_ref().filter(|_| cx.measurement_labels) { Some(Drag::Resize { id, began: true, .. }) => { if let Some(r) = cx.doc.slice_bounds(*id) { o.push(Overlay::Measure { diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index b1df55ab..73d631f0 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -192,7 +192,7 @@ impl FreeTransformTool { } Op::Move => { let v = move_delta(d.start, p, shift); - self.measure = Some((p, cx.offset_label(v.x, v.y))); + self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(v.x, v.y))); Affine::translate(v) } Op::Scale(h) => { @@ -201,13 +201,13 @@ impl FreeTransformTool { (a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.snap_tol()); } let nr = a.transform_rect_bbox(d.rect); - self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); + self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height()))); a } Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt), Op::Rotate => { let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift); - self.measure = Some((p, format!("{:.1}°", -deg))); + self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg))); a } }; @@ -304,7 +304,7 @@ impl Tool for FreeTransformTool { } } o.extend(self.guides.iter().cloned()); - if let (Some((p, t)), true) = (&self.measure, cx.transform_tools_guides) { + if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); } o diff --git a/docs/mcp.md b/docs/mcp.md index 7ea0323a..2a99a8cb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1170,8 +1170,8 @@ point lands: - `alignmentGuides` (on by default): off, no line is drawn along the edge or centre the art lines up with; the art still snaps into line. - `anchorPathLabels` (on by default): off, no "anchor", "center", "path" or "align" label. -- `measurementLabels` (on by default): off, no size or offset readout while drawing, moving, dragging a ruler guide or - drawing and resizing an artboard. +- `measurementLabels` (on by default): off, no size or offset readout while drawing, moving (Free Transform's move + too), dragging a ruler guide, drawing and resizing an artboard, or moving and resizing a slice. - `transformToolsGuides` (on by default): off, no readout while scaling or rotating with the Selection tool, the Free Transform tool or the Rotate, Scale, Shear and Reflect tools. - `objectHighlighting` (on by default; desktop app): off, the Selection tools no longer outline the object under the