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
This commit is contained in:
AlPaka1234 authored and GitHub committed 2026-11-13 20:58:31 +01:00
1 parent cf4efa1833
commit dbdc5b697a
5 files changed
+253 -41

No files matched your search

+48
View File
@@ -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<ItemId>,
/// Items starting at `z` that resize when the gap moves.
pub after: Vec<ItemId>,
}
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<Gap> {
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<ItemId> = items.iter().filter(|(_, b)| across(b) && (end(b) - a).abs() < 0.5).map(|(i, _)| *i).collect();
let after: Vec<ItemId> = 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<ItemId> {
+1 -1
View File
@@ -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};
+8 -38
View File
@@ -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<ItemId>, Vec<ItemId>)> {
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<ItemId> = items.iter().filter(|(_, b)| across(b) && (end(b) - a).abs() < 0.5).map(|(i, _)| *i).collect();
let after: Vec<ItemId> = 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<Value> {
const ID: &str = "gap.move";
let at = p
@@ -3140,19 +3109,20 @@ fn gap_move(s: &mut Session, p: &Value) -> Result<Value> {
.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<Value> {
n += 1;
}
}
Ok(json!({"axis": if vertical { "x" } else { "y" }, "changed": n}))
Ok(json!({"axis": if gap.vertical { "x" } else { "y" }, "changed": n}))
})
}
+107 -2
View File
@@ -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<designcraft_doc::ItemId>,
}
impl GapDrag {
fn from_doc(doc: &designcraft_doc::Document, sr: designcraft_doc::SpreadRef, p: Point) -> Option<Self> {
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<GapDrag>,
active: bool,
guides: Vec<Overlay>,
}
impl Tool for GapTool {
@@ -590,15 +647,18 @@ impl Tool for GapTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
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<Overlay> {
self.guides.clone()
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
+89
View File
@@ -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<Overlay>) {
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));
}