Snap: the nearer of a margin and the baseline grid wins; Type tool frames snap

Resizing or drawing at a zoom where the snap zone covers the baseline
grid's spacing (66% and below with a 12 pt grid) always snapped to a
baseline line, so frames could not reach the top or bottom margin. The
baseline grid and the guides (ruler guides, margins, columns) now
compete by distance and a tie goes to the guide.

Frames drawn with the Type tool snapped nothing; they now snap like the
frame tools' (press point and moving edges, with Shift and Alt).
This commit is contained in:
aiongg
2026-10-07 00:16:33 -04:00
parent 2d6213d40d
commit 576fde980b
4 changed files with 147 additions and 39 deletions
+61
View File
@@ -537,3 +537,64 @@ fn commands_take_non_object_params_without_panicking() {
assert!(r.is_ok(), "{p}: {r:?}");
}
}
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
use designcraft_tools::{PointerEvent, PointerKind};
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
}
#[test]
fn text_frame_resize_snaps_to_the_margins() {
use designcraft_geom::{Point, Vec2};
for zoom in [1.0, 0.5] {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(zoom);
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
// Right handle to 1.5 pt short of the right margin.
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
// Bottom handle to 1.5 pt past the bottom margin.
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
// Top-left corner handle onto the top and left margins.
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
}
}
#[test]
fn type_tool_frames_snap_to_the_margins() {
use designcraft_geom::Point;
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
s.set_tool("type");
let v = ViewInfo::at_zoom(1.0);
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
let before = s.doc().unwrap().doc.spreads[0].items.len();
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
let items = &s.doc().unwrap().doc.spreads[0].items;
assert_eq!(items.len(), before + 1, "a frame was drawn");
let r = items.last().unwrap().bounds();
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
}
+42 -34
View File
@@ -50,6 +50,46 @@ impl FrameTool {
}
}
/// The rectangle a frame-drawing drag from `sa` to `end` (spread space) makes, snapped when
/// snapping is on, and the snap guides to draw. Shift constrains the shape first; only the
/// driving edge snaps, then the other follows. Alt draws from the centre.
pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: Point, mods: Mods) -> (Rect, Vec<crate::Overlay>) {
let r = drag_rect(sa, end, mods);
if !cx.snap.any() {
return (r, vec![]);
}
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, mods.alt);
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
if mods.shift {
if driver_x {
y_edges = [false, false, false];
} else {
x_edges = [false, false, false];
}
}
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: r,
x_edges,
y_edges,
exclude: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
radius: 0.0,
pointer: end,
},
);
let mut r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
if mods.shift {
r = restore_shift_frame(sa, end, r, driver_x, mods.alt);
}
(r, hit.guides)
}
pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect {
let (mut dx, mut dy) = (b.x - a.x, b.y - a.y);
if m.shift {
@@ -365,41 +405,9 @@ impl Tool for FrameTool {
}
out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
} else {
// Shift constrains the shape first. Only the driving edge snaps, then the other follows.
let end = cx.layout.to_spread(sr, ev.pos);
let mut r = drag_rect(sa, end, ev.mods);
if cx.snap.any() {
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, ev.mods.alt);
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
if ev.mods.shift {
if driver_x {
y_edges = [false, false, false];
} else {
x_edges = [false, false, false];
}
}
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: r,
x_edges,
y_edges,
exclude: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
radius: 0.0,
pointer: end,
},
);
r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
if ev.mods.shift {
r = restore_shift_frame(sa, end, r, driver_x, ev.mods.alt);
}
self.guides = hit.guides;
}
let (r, guides) = snapped_drag_rect(cx, sr, sa, end, ev.mods);
self.guides = guides;
self.last = Some((spread_json(sr), r));
out.push(self.preview(spread_json(sr), r));
}
+32 -3
View File
@@ -1,6 +1,8 @@
//! Snapping and smart guides.
//!
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone.
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone; the
//! baseline grid and the guides (ruler guides, margins, columns) count as one pass where the
//! nearer target wins.
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
//! canvas coordinates. Equal spacing draws [`Overlay::Gap`] the same way, through one
//! spread-to-canvas transform. A rotation match draws [`Overlay::Measure`] at the pointer
@@ -374,13 +376,22 @@ fn format_angle(deg: f64) -> String {
fn first_on_axis(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
pass_grid(cx, req, axis, tol)
.or_else(|| pass_baseline(cx, req, axis, tol))
.or_else(|| pass_guides(cx, req, axis, tol))
.or_else(|| nearer(pass_baseline(cx, req, axis, tol), pass_guides(cx, req, axis, tol)))
.or_else(|| pass_align(cx, req, axis, tol))
.or_else(|| pass_spacing(cx, req, axis, tol))
.or_else(|| pass_dimensions(cx, req, axis, tol))
}
/// The baseline grid and the guides (ruler guides, margins, columns) compete by distance, so a
/// margin is reachable between the lines of a fine baseline grid at any zoom. A tie goes to the
/// guide.
fn nearer(baseline: Option<AxisHit>, guide: Option<AxisHit>) -> Option<AxisHit> {
match (baseline, guide) {
(Some(b), Some(g)) => Some(if b.delta.abs() < g.delta.abs() { b } else { g }),
(b, g) => g.or(b),
}
}
/// Document grid. No overlay.
///
/// The interval is the major spacing divided by the subdivision count. A count below 1 counts
@@ -1747,6 +1758,24 @@ mod tests {
assert_eq!(hit.delta.y, 0.0);
}
#[test]
fn nearer_of_margin_and_baseline_wins() {
// Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744.
let doc = Document::new(&NewDocument {
margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 },
..Default::default()
});
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
// At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach.
let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) };
let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) };
assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer");
assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer");
assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin");
}
#[test]
fn rotated_item_matches_its_own_side() {
let mut doc = Document::new(&NewDocument::default());
+12 -2
View File
@@ -5,7 +5,7 @@
use designcraft_geom::Point;
use serde_json::json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json};
#[derive(Default)]
pub struct TypeTool {
@@ -17,6 +17,8 @@ pub struct TypeTool {
/// A press on a table whose whole rows or columns are selected: (frame, press point in
/// spread space) — a drag moves them, a click places the caret.
cell_drag: Option<(u64, Point)>,
/// Snap guides while drawing a frame.
guides: Vec<Overlay>,
/// The press selected a word, line, paragraph or story (several clicks): drags keep it.
unit_selected: bool,
}
@@ -90,7 +92,10 @@ impl Tool for TypeTool {
return vec![];
}
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
// Drawn like the frame tools: the press point and the moving edges snap.
let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa };
let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
self.guides = guides;
let mut out = vec![];
if !self.drawing {
self.drawing = true;
@@ -103,6 +108,7 @@ impl Tool for TypeTool {
out
}
PointerKind::Up => {
self.guides.clear();
let start = self.start.take();
if let Some((fid, from)) = self.cell_drag.take() {
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
@@ -155,6 +161,10 @@ impl Tool for TypeTool {
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
if self.drawing { self.guides.clone() } else { vec![] }
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
match cx.hit(p) {
Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text,