From dbdc5b697ac86ef77da394cd80169b639fc84d67 Mon Sep 17 00:00:00 2001 From: AlPaka1234 <356241429+AlPaka1234@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:58:31 +0100 Subject: [PATCH] Tools: snap Gap tool drags to active snapping settings (#2637) (#2749) * Tools: snap Gap tool drags to active snapping settings (#2637) * Tools: reject unreachable Gap tool snap targets beyond resize clamp * Tools: preserve raw Gap tool delta when no reachable snap matches --- crates/doc/src/edit.rs | 48 ++++++++++++++ crates/doc/src/lib.rs | 2 +- crates/engine/src/cmd/object.rs | 46 +++----------- crates/tools/src/nav.rs | 109 +++++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 89 ++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 41 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index cadc3d419..628fc8ee6 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -28,6 +28,22 @@ pub struct ItemLoc { pub path: ItemPath, } +/// A gap between adjacent items (or between an item and the page edge) on a spread. +#[derive(Clone, Debug, PartialEq)] +pub struct Gap { + /// True for a vertical gap (between left and right neighbours, moving along X); + /// false for a horizontal gap (between top and bottom neighbours, moving along Y). + pub vertical: bool, + /// Leading edge of the gap along its movement axis. + pub a: f64, + /// Trailing edge of the gap along its movement axis. + pub z: f64, + /// Items ending at `a` that resize when the gap moves. + pub before: Vec, + /// Items starting at `z` that resize when the gap moves. + pub after: Vec, +} + impl ItemLoc { /// Top-level index in the spread. pub fn top(&self) -> usize { @@ -648,6 +664,38 @@ impl Document { .map(|it| it.id) } + /// The gap at `p` (spread space) on spread `sr`: between left and right neighbours (vertical + /// gap moving along X), or above and below (horizontal gap moving along Y). + pub fn find_gap(&self, sr: SpreadRef, p: Point) -> Option { + let sp = self.spread(sr)?; + let page = sp.page_at_x(p.x).and_then(|i| sp.pages.get(i)).map(|pg| pg.bounds())?; + let items: Vec<(ItemId, Rect)> = sp + .items + .iter() + .filter(|it| !it.hidden && !it.locked && self.layer(it.layer).is_none_or(|l| !l.locked && l.visible)) + .map(|it| (it.id, it.bounds())) + .collect(); + if items.iter().any(|(_, b)| b.contains(p)) { + return None; + } + // Between left and right neighbours (a vertical gap), or above and below (horizontal). + let side = |vertical: bool| { + let (pos, lo, hi) = if vertical { (p.x, page.x0, page.x1) } else { (p.y, page.y0, page.y1) }; + let across = |b: &Rect| if vertical { b.y0 <= p.y && p.y <= b.y1 } else { b.x0 <= p.x && p.x <= b.x1 }; + let end = |b: &Rect| if vertical { b.x1 } else { b.y1 }; + let start = |b: &Rect| if vertical { b.x0 } else { b.y0 }; + let a = items.iter().filter(|(_, b)| across(b) && end(b) <= pos).map(|(_, b)| end(b)).fold(lo, f64::max); + let z = items.iter().filter(|(_, b)| across(b) && start(b) >= pos).map(|(_, b)| start(b)).fold(hi, f64::min); + let before: Vec = items.iter().filter(|(_, b)| across(b) && (end(b) - a).abs() < 0.5).map(|(i, _)| *i).collect(); + let after: Vec = items.iter().filter(|(_, b)| across(b) && (start(b) - z).abs() < 0.5).map(|(i, _)| *i).collect(); + (!before.is_empty() || !after.is_empty()).then_some(Gap { vertical, a, z, before, after }) + }; + match (side(true), side(false)) { + (Some(v), Some(h)) => Some(if v.z - v.a <= h.z - h.a { v } else { h }), + (v, h) => v.or(h), + } + } + /// The first item on a document or parent spread whose geometry isn't finite /// ([`Item::geometry_is_finite`]). pub fn non_finite_item(&self) -> Option { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f4c8a25b6..329235be9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -52,7 +52,7 @@ pub use datamerge::{ }; pub use designcraft_color as color; pub use designcraft_geom as geom; -pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit}; +pub use edit::{Gap, ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; pub use file_info::FileInfo; pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c41564047..fd0696733 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3101,37 +3101,6 @@ fn apply_custom_frame_grid( } } -/// The gap at `p` on spread `sr`: (vertical gap?, edge a, edge b, ids ending at a, ids starting at b). -pub(crate) fn find_gap(d: &Document, sr: designcraft_doc::SpreadRef, p: Point) -> Option<(bool, f64, f64, Vec, Vec)> { - let sp = d.spread(sr)?; - let page = sp.page_at_x(p.x).and_then(|i| sp.pages.get(i)).map(|pg| pg.bounds())?; - let items: Vec<(ItemId, Rect)> = sp - .items - .iter() - .filter(|it| !it.hidden && !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked && l.visible)) - .map(|it| (it.id, it.bounds())) - .collect(); - if items.iter().any(|(_, b)| b.contains(p)) { - return None; - } - // Between left and right neighbours (a vertical gap), or above and below (horizontal). - let side = |vertical: bool| { - let (pos, lo, hi) = if vertical { (p.x, page.x0, page.x1) } else { (p.y, page.y0, page.y1) }; - let across = |b: &Rect| if vertical { b.y0 <= p.y && p.y <= b.y1 } else { b.x0 <= p.x && p.x <= b.x1 }; - let end = |b: &Rect| if vertical { b.x1 } else { b.y1 }; - let start = |b: &Rect| if vertical { b.x0 } else { b.y0 }; - let a = items.iter().filter(|(_, b)| across(b) && end(b) <= pos).map(|(_, b)| end(b)).fold(lo, f64::max); - let z = items.iter().filter(|(_, b)| across(b) && start(b) >= pos).map(|(_, b)| start(b)).fold(hi, f64::min); - let before: Vec = items.iter().filter(|(_, b)| across(b) && (end(b) - a).abs() < 0.5).map(|(i, _)| *i).collect(); - let after: Vec = items.iter().filter(|(_, b)| across(b) && (start(b) - z).abs() < 0.5).map(|(i, _)| *i).collect(); - (!before.is_empty() || !after.is_empty()).then_some((vertical, a, z, before, after)) - }; - match (side(true), side(false)) { - (Some(v), Some(h)) => Some(if v.2 - v.1 <= h.2 - h.1 { v } else { h }), - (v, h) => v.or(h), - } -} - fn gap_move(s: &mut Session, p: &Value) -> Result { const ID: &str = "gap.move"; let at = p @@ -3140,19 +3109,20 @@ fn gap_move(s: &mut Session, p: &Value) -> Result { .and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .ok_or_else(|| bad(ID, "missing at"))?; let sr = spread_param(p, ID)?; - let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?; + let gap = s.doc()?.doc.find_gap(sr, at).ok_or_else(|| bad(ID, "no gap there"))?; // `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts. - let delta = - p.get("delta").and_then(Value::as_f64).unwrap_or_else(|| p.get(if vertical { "dx" } else { "dy" }).and_then(Value::as_f64).unwrap_or(0.0)); - let _ = (a, z); + let delta = p + .get("delta") + .and_then(Value::as_f64) + .unwrap_or_else(|| p.get(if gap.vertical { "dx" } else { "dy" }).and_then(Value::as_f64).unwrap_or(0.0)); s.edit(|d, _| { let mut n = 0; - for (ids, is_before) in [(&before, true), (&after, false)] { + for (ids, is_before) in [(&gap.before, true), (&gap.after, false)] { for id in ids { let Some(it) = d.item_mut(*id) else { continue }; let b = it.bounds(); let mut to = b; - match (vertical, is_before) { + match (gap.vertical, is_before) { (true, true) => to.x1 = (b.x1 + delta).max(b.x0 + 1.0), (true, false) => to.x0 = (b.x0 + delta).min(b.x1 - 1.0), (false, true) => to.y1 = (b.y1 + delta).max(b.y0 + 1.0), @@ -3170,7 +3140,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result { n += 1; } } - Ok(json!({"axis": if vertical { "x" } else { "y" }, "changed": n})) + Ok(json!({"axis": if gap.vertical { "x" } else { "y" }, "changed": n})) }) } diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index c1e434a8e..8dc30f60b 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -1,6 +1,6 @@ //! Hand (H) and Zoom (Z) tools. -use designcraft_geom::Point; +use designcraft_geom::{Point, Rect}; use serde_json::json; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; @@ -573,13 +573,70 @@ impl Tool for PathTypeTool { } } +struct GapDrag { + vertical: bool, + a: f64, + z: f64, + cross_lo: f64, + cross_hi: f64, + min_delta: f64, + max_delta: f64, + x_edges: [bool; 3], + y_edges: [bool; 3], + exclude: Vec, +} + +impl GapDrag { + fn from_doc(doc: &designcraft_doc::Document, sr: designcraft_doc::SpreadRef, p: Point) -> Option { + let g = doc.find_gap(sr, p)?; + let mut exclude = Vec::with_capacity(g.before.len() + g.after.len()); + exclude.extend_from_slice(&g.before); + exclude.extend_from_slice(&g.after); + let (mut cross_lo, mut cross_hi) = (f64::INFINITY, f64::NEG_INFINITY); + for id in &exclude { + if let Some(it) = doc.item(*id) { + let b = it.bounds(); + let (lo, hi) = if g.vertical { (b.y0, b.y1) } else { (b.x0, b.x1) }; + cross_lo = cross_lo.min(lo); + cross_hi = cross_hi.max(hi); + } + } + if !cross_lo.is_finite() || !cross_hi.is_finite() { + let c = if g.vertical { p.y } else { p.x }; + cross_lo = c; + cross_hi = c; + } + let mut min_delta = f64::NEG_INFINITY; + for id in &g.before { + if let Some(it) = doc.item(*id) { + let b = it.bounds(); + let limit = if g.vertical { b.x0 + 1.0 - b.x1 } else { b.y0 + 1.0 - b.y1 }; + min_delta = min_delta.max(limit); + } + } + let mut max_delta = f64::INFINITY; + for id in &g.after { + if let Some(it) = doc.item(*id) { + let b = it.bounds(); + let limit = if g.vertical { b.x1 - 1.0 - b.x0 } else { b.y1 - 1.0 - b.y0 }; + max_delta = max_delta.min(limit); + } + } + let flags = [!g.before.is_empty(), !g.before.is_empty() && !g.after.is_empty(), !g.after.is_empty()]; + let (x_edges, y_edges) = if g.vertical { (flags, [false; 3]) } else { ([false; 3], flags) }; + Some(Self { vertical: g.vertical, a: g.a, z: g.z, cross_lo, cross_hi, min_delta, max_delta, x_edges, y_edges, exclude }) + } +} + /// Gap tool (U): drag the space between objects (or between an object and the page edge); the /// objects on both sides resize so the gap moves. #[derive(Default)] pub struct GapTool { /// Spread, its spread → canvas transform and the press point in spread coordinates. start: Option<(designcraft_doc::SpreadRef, designcraft_geom::Affine, Point)>, + gap: Option, active: bool, + guides: Vec, } impl Tool for GapTool { @@ -590,15 +647,18 @@ impl Tool for GapTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { + self.guides.clear(); + self.gap = None; if let Some((sr, p)) = cx.layout.spread_at(ev.pos) { self.start = Some((sr, cx.layout.xf(sr), p)); + self.gap = GapDrag::from_doc(cx.doc, sr, p); } self.active = false; vec![] } PointerKind::Drag => { let Some((sr, xf, p)) = self.start else { return vec![] }; - let d = (xf.inverse() * ev.pos) - p; + let mut d = (xf.inverse() * ev.pos) - p; let mut out = vec![]; if !self.active { if d.hypot() < cx.tol(2.0) { @@ -607,6 +667,45 @@ impl Tool for GapTool { self.active = true; out.push(Action::Begin("Move Gap".into())); } + self.guides.clear(); + if !ev.mods.cmd + && cx.snap.any() + && let Some(gap) = &self.gap + { + let raw = if gap.vertical { d.x } else { d.y }; + let proposed = if gap.vertical { + Rect::new(gap.a + raw, gap.cross_lo, gap.z + raw, gap.cross_hi) + } else { + Rect::new(gap.cross_lo, gap.a + raw, gap.cross_hi, gap.z + raw) + }; + let hit = crate::snap::snap( + cx, + crate::SnapRequest { + spread: sr, + gesture: crate::Gesture::Resize, + rect: proposed, + x_edges: gap.x_edges, + y_edges: gap.y_edges, + exclude: &gap.exclude, + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: p + d, + }, + ); + let snap_delta = if gap.vertical { hit.delta.x } else { hit.delta.y }; + let snapped = raw + snap_delta; + if snap_delta.is_finite() && snapped >= gap.min_delta - 1e-6 && snapped <= gap.max_delta + 1e-6 { + if gap.vertical { + d.x = snapped; + } else { + d.y = snapped; + } + self.guides = hit.guides; + } + } out.push(Action::Preview( "gap.move".into(), serde_json::json!({"spread": crate::spread_json(sr), "at": [p.x, p.y], "dx": d.x, "dy": d.y}), @@ -615,12 +714,18 @@ impl Tool for GapTool { } PointerKind::Up => { self.start = None; + self.gap = None; + self.guides.clear(); if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] } } _ => vec![], } } + fn overlays(&self, _cx: &ToolContext) -> Vec { + self.guides.clone() + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Crosshair } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 40a494679..670ead0ec 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2416,3 +2416,92 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); } + +#[test] +fn gap_tool_follows_snapping_settings() { + let mut d = Document::new(&NewDocument::default()); + add_rect(&mut d, Rect::new(100.0, 100.0, 180.0, 200.0)); + add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0)); + add_rect(&mut d, Rect::new(250.0, 250.0, 320.0, 350.0)); + Arc::make_mut(&mut d.spreads[0]).pages[0].guides.push(Guide { + orientation: Orientation::Vertical, + position: 225.0, + spread: false, + locked: false, + layer: None, + liquid: false, + }); + Arc::make_mut(&mut d.spreads[0]).pages[0].guides.push(Guide { + orientation: Orientation::Vertical, + position: 310.0, + spread: false, + locked: false, + layer: None, + liquid: false, + }); + let sel = Selection::default(); + let c = Cache::new(); + let l = CanvasLayout::new(&d, false); + let p0 = spread_pt(&l, 190.0, 150.0); + + let drag_dx = |snap: SnapView, mods: Mods, raw_dx: f64| -> (f64, Vec) { + let mut t = create("gap"); + let cx = ToolContext { snap, ..ctx(&d, &sel, &c, &l) }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p0.x + raw_dx, p0.y).with_mods(mods)); + let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(cmd, "gap.move"); + let overlays = t.overlays(&cx); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p0.x + raw_dx, p0.y).with_mods(mods)); + (p["dx"].as_f64().unwrap(), overlays) + }; + + // Ruler guide at x = 225: gap right edge is 200 + 23 = 223, snaps +2 pt to 225. + let (dx, _) = drag_dx(SnapView::FACTORY, Mods::default(), 23.0); + assert!((dx - 25.0).abs() < 1e-6, "expected snap to guide (dx=25), got {dx}"); + + // Snapping off: stays at raw 23.0. + let (dx, overlays) = drag_dx(SnapView::OFF, Mods::default(), 23.0); + assert!((dx - 23.0).abs() < 1e-6, "expected unsnapped dx=23, got {dx}"); + assert!(overlays.is_empty(), "expected no overlays when snap is off: {overlays:?}"); + + // Cmd suspends snapping: stays at raw 23.0. + let (dx, overlays) = drag_dx(SnapView::FACTORY, Mods { cmd: true, ..Default::default() }, 23.0); + assert!((dx - 23.0).abs() < 1e-6, "expected unsnapped dx=23 with Cmd, got {dx}"); + assert!(overlays.is_empty(), "expected no overlays with Cmd: {overlays:?}"); + + // Smart guide alignment to third item's left edge (x = 250): gap left edge 180 + 68 = 248, snaps to 250 (dx = 70). + let (dx, overlays) = drag_dx(SnapView::FACTORY, Mods::default(), 68.0); + assert!((dx - 70.0).abs() < 1e-6, "expected smart-guide snap (dx=70), got {dx}"); + assert!(overlays.iter().any(|o| matches!(o, Overlay::Guide { .. })), "expected alignment guide overlay: {overlays:?}"); + + // Smart guides disabled: no snap to third item. + let no_smart = SnapView { smart_guides: false, ..SnapView::FACTORY }; + let (dx, overlays) = drag_dx(no_smart, Mods::default(), 68.0); + assert!((dx - 68.0).abs() < 1e-6, "expected no smart-guide snap when disabled, got {dx}"); + assert!(overlays.is_empty(), "expected no guide overlay when smart_guides=false: {overlays:?}"); + + // Ruler guide beyond gap_move's minimum-width clamp (right item [200..300] clamps at x=299, max_delta=99): + // dragging 108 pt reaches x=308 near guide x=310 (dx=110), which must be rejected as unreachable. + let (dx, overlays) = drag_dx(no_smart, Mods::default(), 108.0); + assert!((dx - 108.0).abs() < 1e-6, "expected unreachable snap target to be rejected, got {dx}"); + assert!(overlays.is_empty(), "expected no guide overlay for unreachable snap target: {overlays:?}"); + + // With two left neighbours of different widths ([100..180] and [140..180]), dragging left by 60 pt + // without a snap match preserves the raw delta (-60) instead of clamping it to the narrower item (-39). + let mut d2 = Document::new(&NewDocument::default()); + add_rect(&mut d2, Rect::new(100.0, 100.0, 180.0, 150.0)); + add_rect(&mut d2, Rect::new(140.0, 150.0, 180.0, 200.0)); + add_rect(&mut d2, Rect::new(200.0, 100.0, 300.0, 200.0)); + let l2 = CanvasLayout::new(&d2, false); + let q0 = spread_pt(&l2, 190.0, 150.0); + let mut t2 = create("gap"); + let cx2 = ToolContext { snap: SnapView::FACTORY, ..ctx(&d2, &sel, &c, &l2) }; + t2.pointer(&cx2, &PointerEvent::new(PointerKind::Down, q0.x, q0.y)); + let drag2 = t2.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, q0.x - 60.0, q0.y)); + let Some(Action::Preview(cmd2, p2)) = drag2.last() else { panic!("{drag2:?}") }; + assert_eq!(cmd2, "gap.move"); + assert!((p2["dx"].as_f64().unwrap() - (-60.0)).abs() < 1e-6, "expected raw dx=-60 preserved, got {p2}"); + assert!(t2.overlays(&cx2).is_empty(), "expected no guide overlay without snap match"); + t2.pointer(&cx2, &PointerEvent::new(PointerKind::Up, q0.x - 60.0, q0.y)); +}