diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0f..8b4e7713e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); +} diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 87b4284cc..4b52e2ee5 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -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) { + 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)); } diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..142525dec 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -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 { 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, guide: Option) -> Option { + 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()); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e84f2644c..e6ef1a5fe 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -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, /// 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 { + 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,