Snap: smart guides beat the baseline grid; a box centres between two others

On the vertical axis the baseline grid was tried before smart guides.
It snaps while hidden, and with lines every 12 pt and a box's top,
centre and bottom each reaching 4 pt either side, one of them is nearly
always in the zone. It took almost every vertical move and drew nothing,
so object alignment and equal spacing hardly ever showed on y. The
baseline pass now runs after the smart guide passes.

Smart spacing also only matched a gap between two other boxes. A box
dragged between two neighbours now snaps to where both of its gaps are
equal, and both gaps are marked.

Fixes #188

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5fQBTFb4hhKXsCFYitZ1P
This commit is contained in:
noah1400
2026-10-10 09:06:48 +00:00
co-authored by Claude Opus 5.5
parent 74a81f9fb0
commit 8b940b4bcb
2 changed files with 116 additions and 8 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document grid, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing (centred between two neighbours too), matching dimensions, the baseline grid and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
+115 -7
View File
@@ -372,13 +372,15 @@ fn format_angle(deg: f64) -> String {
if nearest.is_finite() && (deg - nearest).abs() < 1e-6 { format!("{nearest:.0}") } else { format!("{deg:.1}") }
}
/// The baseline grid comes last: its lines are dense and it snaps while hidden, so ahead of
/// smart guides it would take nearly every vertical move and draw nothing (#188).
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(|| pass_align(cx, req, axis, tol))
.or_else(|| pass_spacing(cx, req, axis, tol))
.or_else(|| pass_dimensions(cx, req, axis, tol))
.or_else(|| pass_baseline(cx, req, axis, tol))
}
/// Document grid. No overlay.
@@ -525,7 +527,8 @@ fn offer_enabled(best: &mut Option<Best>, moving: Rect, flags: [bool; 3], axis:
/// stationary box. A box counts only when its range overlaps on the other axis. The nearest
/// box on a side that overlaps the mover, or a zero gap, produces no gap, and a farther box
/// is not used instead. A candidate shifts the mover so one of its gaps equals one stationary
/// nearest-neighbor gap. The shift has to fall inside the zone. The closest shift wins.
/// nearest-neighbor gap, or, with a box on both sides, so its two gaps are equal (centered
/// between them). The shift has to fall inside the zone. The closest shift wins.
/// One gap overlay is drawn for every gap of that length, including the moving gap after the
/// shift. Create, resize, rotate, and point do not match.
fn pass_spacing(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
@@ -538,20 +541,22 @@ fn pass_spacing(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -
let mover = interval_on(req.rect, axis)?;
let boxes = spacing_intervals(cx, req, axis, &mover);
let stationary = stationary_gaps(&boxes);
if stationary.is_empty() {
return None;
}
let low = nearest_side(&mover, &boxes, None, true);
let high = nearest_side(&mover, &boxes, None, false);
let mut best: Option<SpacingChoice> = None;
if let Some(n) = nearest_side(&mover, &boxes, None, true) {
if let Some(n) = &low {
for g in &stationary {
offer_spacing(&mut best, g.gap - n.gap, g.gap, tol);
}
}
if let Some(n) = nearest_side(&mover, &boxes, None, false) {
if let Some(n) = &high {
for g in &stationary {
offer_spacing(&mut best, n.gap - g.gap, g.gap, tol);
}
}
if let (Some(l), Some(h)) = (&low, &high) {
offer_spacing(&mut best, (h.gap - l.gap) * 0.5, (l.gap + h.gap) * 0.5, tol);
}
let choice = best?;
let shifted = Interval { lo: mover.lo + choice.delta, hi: mover.hi + choice.delta, perp_lo: mover.perp_lo, perp_hi: mover.perp_hi };
if !shifted.lo.is_finite() || !shifted.hi.is_finite() {
@@ -1860,6 +1865,109 @@ mod tests {
assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Gap { .. })));
}
fn doc_with_rects(rects: &[Rect]) -> Document {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
for &r in rects {
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
doc
}
fn gap_overlays(guides: &[Overlay]) -> usize {
guides.iter().filter(|g| matches!(g, Overlay::Gap { .. })).count()
}
// #188: a box dragged between two others snaps to the middle and marks both gaps.
#[test]
fn move_centers_between_two_boxes_side_by_side() {
// Neighbours end at 40 and start at 160. The 30 wide mover at [83,113] is 2 left of centre.
let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[]));
assert!((hit.delta.x - 2.0).abs() < 1e-6, "{}", hit.delta.x);
assert_eq!(gap_overlays(&hit.guides), 2);
}
#[test]
fn move_centers_between_two_boxes_stacked() {
let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 30.0, 40.0), Rect::new(0.0, 160.0, 30.0, 200.0)]);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(&cx, y_req(Rect::new(5.0, 83.0, 25.0, 113.0)));
assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y);
assert_eq!(gap_overlays(&hit.guides), 2);
}
fn with_baseline_grid(mut doc: Document) -> Document {
doc.settings.baseline_grid.start = 36.0;
doc.settings.baseline_grid.increment = 12.0;
doc
}
// #188: with Snap to Guides on, the hidden baseline grid took vertical moves first.
#[test]
fn vertical_centering_beats_a_closer_baseline() {
// Middle at 200. Mover centre 198 (+2). Its top and bottom are 3 from lines 180 and 216.
let doc = with_baseline_grid(doc_with_rects(&[Rect::new(100.0, 100.0, 130.0, 140.0), Rect::new(100.0, 260.0, 130.0, 300.0)]));
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
let hit = snap(&cx, y_req(Rect::new(105.0, 183.0, 125.0, 213.0)));
assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y);
assert_eq!(gap_overlays(&hit.guides), 2);
}
#[test]
fn vertical_alignment_beats_a_closer_baseline() {
// Top 107 is 1 from the line at 108 and 2 from the other box's top at 105.
let doc = with_baseline_grid(doc_with_rects(&[Rect::new(300.0, 105.0, 340.0, 135.0)]));
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let cx = ctx_on(&doc, &sel, &cache, &layout);
let hit = snap(&cx, y_req(Rect::new(100.0, 107.0, 140.0, 137.0)));
assert!((hit.delta.y - -2.0).abs() < 1e-6, "{}", hit.delta.y);
assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Guide { .. })));
}
#[test]
fn move_off_center_outside_the_zone_does_not_center() {
// 10 off centre, zone 4.
let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(&cx, move_req(Rect::new(95.0, 5.0, 125.0, 25.0), &[]));
assert_eq!(hit.delta.x, 0.0);
assert_eq!(gap_overlays(&hit.guides), 0);
}
#[test]
fn centering_needs_smart_spacing() {
let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]);
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
cx.snap.smart_spacing = false;
let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[]));
assert_eq!(hit.delta.x, 0.0);
}
#[test]
fn create_does_not_match_spacing() {
let mut doc = Document::new(&NewDocument::default());