From fdb7d7a18320b85888baf45d77cece7a888b8df5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 07:15:12 +0700 Subject: [PATCH] Part of #812: Smart Guides label an object's centre and anchors on hover With Smart Guides on, hovering the Selection tool near an object's centre or one of its anchors now labels it ("center", "anchor"), as Illustrator does, and a press there grabs the object by that point, so the point follows the pointer and snaps. Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 74 +++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 9bd265c20..4a3a4dca2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -71,6 +71,8 @@ pub struct SelectionTool { targets: Option, moving: Option, guide: GuideEdit, + /// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]). + hover: Option<(Point, &'static str)>, } /// Snapping for the selection moved as a whole, its targets gathered when the move begins: View → @@ -163,10 +165,36 @@ fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) } +/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides +/// to label it while hovering. +const HOVER_REACH: f64 = 6.0; + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) } + + /// With Smart Guides on, the anchor or the centre of an object near `p` (the one under the + /// pointer, else a selected one) and its label, "anchor" or "center", as Illustrator shows + /// them while hovering (#812). A press there grabs the object by that point. + fn hover_point(cx: &ToolContext, p: Point) -> Option<(Point, &'static str)> { + if !cx.smart_guides { + return None; + } + let reach = cx.tol(HOVER_REACH); + let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)); + let nearest = |points: &mut dyn Iterator| { + points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p))) + }; + under.into_iter().chain(cx.selection.objects.iter().copied()).find_map(|id| { + let n = cx.doc.node(id)?; + let anchor = n.path_data().and_then(|pd| nearest(&mut pd.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)))); + anchor.map(|q| (q, "anchor")).or_else(|| { + let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center(); + (c.distance(p) <= reach).then_some((c, "center")) + }) + }) + } } impl Tool for SelectionTool { @@ -188,6 +216,11 @@ impl Tool for SelectionTool { } let p = ev.pos; let m = ev.mods; + // A press near the hovered centre or anchor grabs the object by it. + let grab = self.hover.take().map(|(q, _)| q).filter(|q| q.distance(p) <= cx.tol(HOVER_REACH)).unwrap_or(p); + if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) { + self.hover = Self::hover_point(cx, p); + } match (ev.kind, self.state.clone()) { (PointerKind::DoubleClick, _) => { self.state = State::Idle; @@ -278,7 +311,7 @@ impl Tool for SelectionTool { } else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt { key = Some(top); } - self.state = State::Moving { start: p, began: false, deselect, key }; + self.state = State::Moving { start: grab, began: false, deselect, key }; out } None => { @@ -397,6 +430,12 @@ impl Tool for SelectionTool { } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); + if let (Some((q, label)), State::Idle) = (self.hover, &self.state) { + let style = crate::guides::GuideStyle::of(cx); + if style.labels { + o.push(Overlay::Label { p: q, text: label.into(), color: style.color }); + } + } if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p, text: t.clone() }); } @@ -483,6 +522,39 @@ mod tests { PointerEvent::new(kind, x, y) } + /// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a + /// press there grabs the object by that point. + #[test] + fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let label = |t: &SelectionTool| { + t.overlays(&cx).iter().find_map(|o| match o { + Overlay::Label { p, text, .. } => Some((*p, text.clone())), + _ => None, + }) + }; + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); + assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string()))); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0)); + assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string()))); + t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0)); + assert_eq!(label(&t), None, "away from both"); + // Grabbed by its centre, the object's centre follows the pointer. + t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0)); + t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0)); + assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}"); + // With Smart Guides off, nothing is labelled. + let off = ToolContext { smart_guides: false, ..cx }; + let mut t = SelectionTool::default(); + t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0)); + assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. }))); + } + #[test] fn click_selects_object() { let (d, id) = doc_with_rect();