From fe54a974c53c0faec42429653093cfa89b232c87 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:52:49 -0400 Subject: [PATCH] M2.8: snapping and smart guides (page, margins, columns, guides, objects) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 11 ++-- crates/tools/src/frame.rs | 22 ++++++-- crates/tools/src/lib.rs | 1 + crates/tools/src/select.rs | 20 +++++-- crates/tools/src/snap.rs | 107 +++++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 crates/tools/src/snap.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3dd57162..3aabba3f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -60,11 +60,16 @@ fn tool_gesture_creates_one_undo_step() { s.set_tool("selection"); s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 30.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 70.0, 30.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 40.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 40.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 40.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 40.0), v).unwrap(); let st = s.doc().unwrap(); assert_eq!(st.history.undo.len(), 2); - assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 40.0); + assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 50.0); + // A move that ends near the margin snaps to it. + s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 48.0, 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 48.0, 30.0), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].items[0].bounds().x0, 36.0); } #[test] diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 3066fa36..51e05ca2 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -11,12 +11,13 @@ pub struct FrameTool { start: Option, cur: Point, active: bool, + guides: Vec, } impl FrameTool { pub fn new(id: &str) -> Self { let id = crate::tool_info(id).map(|t| t.id).unwrap_or("rectangleFrame"); - Self { id, start: None, cur: Point::ZERO, active: false } + Self { id, start: None, cur: Point::ZERO, active: false, guides: vec![] } } fn kind(&self) -> (&'static str, &'static str) { match self.id { @@ -54,7 +55,11 @@ impl Tool for FrameTool { let (shape, content) = self.kind(); match ev.kind { PointerKind::Down => { - self.start = Some(ev.pos); + let pos = match cx.layout.spread_at(ev.pos) { + Some((sr, sp)) if cx.snap => crate::snap::snap_point(cx, sr, sp).0 + cx.layout.offset(sr), + _ => ev.pos, + }; + self.start = Some(pos); self.cur = ev.pos; self.active = false; vec![] @@ -67,6 +72,14 @@ impl Tool for FrameTool { } let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let off = cx.layout.offset(sr); + let mut ev = *ev; + self.guides.clear(); + if cx.snap && !ev.mods.shift { + let (p, g) = crate::snap::snap_point(cx, sr, ev.pos - off); + ev.pos = p + off; + self.guides = g; + } + let ev = &ev; let mut out = vec![]; if !self.active { self.active = true; @@ -93,6 +106,7 @@ impl Tool for FrameTool { out } PointerKind::Up => { + self.guides.clear(); let was = self.active; self.active = false; let start = self.start.take(); @@ -118,7 +132,9 @@ impl Tool for FrameTool { if let (true, Some(a)) = (self.active, self.start) { let r = Rect::from_points(a, self.cur); let _ = cx; - return vec![Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }]; + let mut v = self.guides.clone(); + v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }); + return v; } vec![] } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 167c01c1..61b2ce39 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -14,6 +14,7 @@ pub mod layout; mod nav; mod pen; pub mod select; +pub mod snap; mod text; use designcraft_compose::Cache; diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 84d59f97..1c9e1bb4 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -14,7 +14,7 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool enum Drag { None, Pending { start: Point, hit: bool }, - Move { start: Point, origin_spread: SpreadRef }, + Move { start: Point, origin_spread: SpreadRef, bounds0: Option }, Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef }, Marquee { start: Point, cur: Point }, Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point }, @@ -47,11 +47,12 @@ pub struct SelectionTool { direct: bool, drag: Drag, hover_handle: Option, + guides: Vec, } impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![] } } } @@ -201,7 +202,7 @@ impl Tool for SelectionTool { } if hit && !cx.selection.items.is_empty() { let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0)); - self.drag = Drag::Move { start, origin_spread: sr }; + self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_bounds() }; let label = if ev.mods.alt { "Duplicate" } else { "Move" }; let mut v = vec![Action::Begin(label.into())]; v.extend(self.pointer(cx, ev)); @@ -211,7 +212,7 @@ impl Tool for SelectionTool { vec![] } } - Drag::Move { start, origin_spread } => { + Drag::Move { start, origin_spread, bounds0 } => { let mut d: Vec2 = p - start; if ev.mods.shift { if d.x.abs() > d.y.abs() { @@ -220,6 +221,15 @@ impl Tool for SelectionTool { d.x = 0.0; } } + self.guides.clear(); + if cx.snap + && let Some(b0) = bounds0 + { + let off = cx.layout.offset(origin_spread); + let snap = crate::snap::snap_rect(cx, origin_spread, b0 + d - off, &cx.selection.items); + d += snap.delta; + self.guides = snap.guides; + } // Dragging to another spread moves the items there. let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread); let mut params = json!({"dx": d.x, "dy": d.y, "copy": ev.mods.alt}); @@ -250,6 +260,7 @@ impl Tool for SelectionTool { Drag::None => vec![], }, PointerKind::Up => { + self.guides.clear(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } => vec![Action::Commit], @@ -308,6 +319,7 @@ impl Tool for SelectionTool { fn overlays(&self, _cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + Drag::Move { .. } => self.guides.clone(), _ => vec![], } } diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs new file mode 100644 index 00000000..29328fc4 --- /dev/null +++ b/crates/tools/src/snap.rs @@ -0,0 +1,107 @@ +//! Snapping and smart guides: page edges and centres, margins, column guides, ruler guides and +//! other objects' edges/centres on the same spread. Returns the snap correction and the guide +//! lines to draw (canvas coordinates). + +use designcraft_doc::{ItemId, Orientation, SpreadRef}; +use designcraft_geom::{Point, Rect, Vec2}; + +use crate::{Overlay, ToolContext}; + +#[derive(Clone, Debug, Default)] +pub struct Snap { + pub delta: Vec2, + pub guides: Vec, +} + +#[derive(Clone, Copy)] +struct Target { + v: f64, + /// Extent of the target along the other axis (for drawing the guide). + lo: f64, + hi: f64, + smart: bool, +} + +fn targets(cx: &ToolContext, sr: SpreadRef, exclude: &[ItemId]) -> (Vec, Vec) { + let mut xs = Vec::new(); + let mut ys = Vec::new(); + let Some(sp) = cx.doc.spread(sr) else { return (xs, ys) }; + let push = |v: &mut Vec, val: f64, lo: f64, hi: f64, smart: bool| v.push(Target { v: val, lo, hi, smart }); + for p in &sp.pages { + let b = p.bounds(); + let m = p.margin_rect(); + for x in [b.x0, b.center().x, b.x1, m.x0, m.x1] { + push(&mut xs, x, b.y0, b.y1, false); + } + for y in [b.y0, b.center().y, b.y1, m.y0, m.y1] { + push(&mut ys, y, b.x0, b.x1, false); + } + for c in p.column_rects() { + push(&mut xs, c.x0, b.y0, b.y1, false); + push(&mut xs, c.x1, b.y0, b.y1, false); + } + for g in &p.guides { + match g.orientation { + Orientation::Vertical => push(&mut xs, g.position, b.y0, b.y1, false), + Orientation::Horizontal => push(&mut ys, g.position, b.x0, b.x1, false), + } + } + } + for it in &sp.items { + if exclude.contains(&it.id) || it.hidden { + continue; + } + let b = it.bounds(); + for x in [b.x0, b.center().x, b.x1] { + push(&mut xs, x, b.y0, b.y1, true); + } + for y in [b.y0, b.center().y, b.y1] { + push(&mut ys, y, b.x0, b.x1, true); + } + } + (xs, ys) +} + +fn best(cands: &[f64], targets: &[Target], tol: f64) -> Option<(f64, Target)> { + let mut out: Option<(f64, Target)> = None; + for &c in cands { + for t in targets { + let d = t.v - c; + if d.abs() <= tol && out.is_none_or(|(od, _)| d.abs() < od.abs()) { + out = Some((d, *t)); + } + } + } + out +} + +/// Snap a moving rect (spread coords). Edges and centre snap independently per axis. +pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -> Snap { + let tol = cx.tol(5.0); + let (xs, ys) = targets(cx, sr, exclude); + let mut s = Snap::default(); + let off = cx.layout.offset(sr); + if let Some((d, t)) = best(&[r.x0, r.center().x, r.x1], &xs, tol) { + s.delta.x = d; + let lo = t.lo.min(r.y0) - 6.0; + let hi = t.hi.max(r.y1) + 6.0; + s.guides.push(guide(Point::new(t.v, lo) + off, Point::new(t.v, hi) + off, t.smart)); + } + if let Some((d, t)) = best(&[r.y0, r.center().y, r.y1], &ys, tol) { + s.delta.y = d; + let lo = t.lo.min(r.x0) - 6.0; + let hi = t.hi.max(r.x1) + 6.0; + s.guides.push(guide(Point::new(lo, t.v) + off, Point::new(hi, t.v) + off, t.smart)); + } + s +} + +/// Snap a single point (drawing tools). +pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec) { + let s = snap_rect(cx, sr, Rect::from_points(p, p), &[]); + (p + s.delta, s.guides) +} + +fn guide(a: Point, b: Point, smart: bool) -> Overlay { + if smart { Overlay::Guide { a, b } } else { Overlay::Line { a, b, color: [255, 0, 255], dashed: false } } +}