From 1160aa433641908e88faeb4ec4c398418fabae60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:56 +0000 Subject: [PATCH] refactor(tools): move rulerguide.rs tests to rulerguide/tests.rs --- crates/tools/src/rulerguide.rs | 605 +-------------------------- crates/tools/src/rulerguide/tests.rs | 602 ++++++++++++++++++++++++++ 2 files changed, 603 insertions(+), 604 deletions(-) create mode 100644 crates/tools/src/rulerguide/tests.rs diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 5473d620f..87822f070 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -343,607 +343,4 @@ fn on_ruler(cx: &ToolContext, vertical: bool, p: Point) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use crate::{Mods, ScreenFrame}; - use vectorcraft_doc::{Guide, Selection}; - use vectorcraft_geom::Vec2; - - fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { - PointerEvent::new(kind, x, y).with_mods(mods) - } - - fn guides_doc() -> vectorcraft_doc::Document { - let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; - d - } - - #[test] - fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { - let (d, s, p) = (guides_doc(), Selection::default(), paint()); - let c = cx(&d, &s, &p); - assert_eq!(guide_at(&c, Point::new(302.0, 50.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(50.0, 398.0)), Some(1)); - // Where they cross, the nearer one. - assert_eq!(guide_at(&c, Point::new(301.0, 398.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(305.0, 50.0)), None, "beyond the 3 px tolerance"); - let hidden = ToolContext { guides: false, ..cx(&d, &s, &p) }; - assert_eq!(guide_at(&hidden, Point::new(300.0, 50.0)), None); - } - - #[test] - fn a_drag_moves_the_selected_guides_in_one_step() { - let d = guides_doc(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut g = GuideEdit::default(); - let m = Mods::default(); - assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); - // The tool sees the guide selected from now on. - let mut s = s; - s.set_guides([0]); - let c = cx(&d, &s, &p); - let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); - assert_eq!(a[0], Action::Begin("Move Guide".into())); - assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 40.0, "dy": 10.0, "copy": false}))); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "X: 340.00 pt")); - assert_eq!(g.cursor(&c, Point::ZERO), Some(Cursor::ResizeH)); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 60.0, m)), Some(vec![Action::Commit])); - assert!(!g.busy() && g.pointer(&c, &ev(PointerKind::Drag, 0.0, 0.0, m)).is_none()); - } - - #[test] - fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0]); - let p = paint(); - let drag_to = |c: &ToolContext, x: f64, mods: Mods| { - let mut g = GuideEdit::default(); - g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); - let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, mods)).unwrap(); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - (300.0 + v["dx"].as_f64().unwrap(), g.overlays(c)) - }; - // Smart Guides: onto the rect's right edge (x = 200), labelled. - let (x, ov) = drag_to(&cx(&d, &s, &p), 203.0, Mods::default()); - assert_eq!(x, 200.0); - assert!(matches!(&ov[0], Overlay::Label { .. })); - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&off, 203.0, Mods::default()).0, 203.0); - // Shift: the ruler's ticks (labels every 50 pt at 100 %, ticks every 5 pt). - assert_eq!(drag_to(&off, 223.0, Mods { shift: true, ..Default::default() }).0, 225.0); - let zoomed = ToolContext { zoom: 5.0, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&zoomed, 223.4, Mods { shift: true, ..Default::default() }).0, 223.0, "every point at 500 %"); - let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions"); - } - - /// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its - /// centre's line) and anchors, and on the artboard's edges and centre, labelled. - #[test] - fn a_new_guide_snaps_to_the_art_and_the_artboard() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let m = Mods::default(); - let drag = |vertical: bool, v: f64| { - let mut g = NewGuide::new(&c, vertical, None); - let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) }; - let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true); - let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") }; - assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical))); - let label = g.overlays(&c).iter().find_map(|o| match o { - Overlay::Label { text, .. } => Some(text.clone()), - _ => None, - }); - (p["pos"].as_f64().unwrap(), label) - }; - assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints"); - assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners"); - assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge"); - assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre"); - assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach"); - } - - /// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping - /// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what - /// shows by it. - #[test] - fn a_new_guide_follows_the_smart_guide_preferences() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let drag = |c: &ToolContext| { - let mut g = NewGuide::new(c, true, None); - let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true); - let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") }; - (p["pos"].as_f64().unwrap(), g.overlays(c)) - }; - let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) }); - assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}"); - let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) }); - assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said"); - } - - #[test] - fn a_new_guide_is_made_on_release_over_the_canvas_only() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let m = Mods::default(); - let mut g = NewGuide::new(&c, false, Some(0)); - let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0})); - // Off the canvas (still over the ruler): nothing yet. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt")); - // The same place again: no new preview. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]); - // Back onto the ruler it goes, and comes back over the canvas. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]); - assert!(g.overlays(&c).is_empty()); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]); - // Shift: the ruler's ticks. - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]); - // Released off the canvas: none. - let mut g = NewGuide::new(&c, true, None); - g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]); - } - - #[test] - fn new_ruler_guide_alt_switches_axis_and_uses_the_release_position() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - for source_vertical in [false, true] { - let mut g = NewGuide::new(&c, source_vertical, Some(0)); - for (i, alt) in [false, true, false, true].into_iter().enumerate() { - let vertical = source_vertical != alt; - let at = if vertical { 80.0 } else { 120.0 }; - let mut expected = vec![]; - if i == 0 { - expected.push(Action::Begin("New Guide".into())); - } - expected.push(Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": at, "artboard": 0}))); - assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 80.0, 120.0, Mods { alt, ..Default::default() }), true), expected); - let label = format!("{}: {at:.2} pt", if vertical { "X" } else { "Y" }); - assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &label)); - } - // Releasing at a new position with Alt released uses both final coordinates and modifiers. - let at = if source_vertical { 190.0 } else { 240.0 }; - assert_eq!( - g.pointer(&c, &ev(PointerKind::Up, 190.0, 240.0, Mods::default()), true), - vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical, "pos": at, "artboard": 0})), Action::Commit] - ); - assert!(g.overlays(&c).is_empty()); - } - } - - #[test] - fn new_ruler_guide_alt_switches_even_when_coordinates_are_equal() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - for source_vertical in [false, true] { - let mut g = NewGuide::new(&c, source_vertical, None); - g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods::default()), true); - for alt in [true, false] { - assert_eq!( - g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods { alt, ..Default::default() }), true), - vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical != alt, "pos": 80.0}))] - ); - } - } - } - - #[test] - fn new_ruler_guide_alt_snaps_on_the_effective_axis() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; - let grid = ToolContext { snap_to_grid: true, ..off }; - let pixel = ToolContext { snap_to_pixel: true, ..off }; - let smart = cx(&d, &s, &p); - for (c, shift, x, y, expected_x, expected_y) in [ - (&off, true, 223.0, 237.0, 225.0, 235.0), - (&grid, false, 226.0, 237.0, 225.0, 234.0), - (&pixel, false, 226.4, 237.6, 226.0, 238.0), - (&smart, false, 203.0, 102.0, 200.0, 100.0), - ] { - for source_vertical in [false, true] { - let mut g = NewGuide::new(c, source_vertical, None); - let actions = g.pointer(c, &ev(PointerKind::Drag, x, y, Mods { alt: true, shift, ..Default::default() }), true); - let expected = if source_vertical { expected_y } else { expected_x }; - assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": !source_vertical, "pos": expected})))); - } - } - } - - /// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its - /// distance), not the edges' midpoints. - #[test] - fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0]); - let p = paint(); - let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; - let moved_to = |c: &ToolContext, x: f64| { - let mut g = GuideEdit::default(); - g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); - let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap(); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - 300.0 + v["dx"].as_f64().unwrap() - }; - assert_eq!(moved_to(&off, 201.5), 200.0); - assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor"); - assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance"); - let neither = ToolContext { snap_to_point: false, ..off }; - assert_eq!(moved_to(&neither, 201.5), 201.5); - let mut g = NewGuide::new(&off, true, None); - let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true); - assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0})))); - } - - /// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a - /// point with Snap to Point there. - #[test] - fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { - let (mut d, _) = doc_with_rect(); - let id = d.next_artboard_id(); - d.artboards.push(vectorcraft_doc::Artboard { - id, - name: "B".into(), - rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); - assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end"); - assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard"); - let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap(); - assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0)); - assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5)); - } - - #[test] - fn shift_click_toggles_and_alt_drag_copies() { - let d = guides_doc(); - let mut s = Selection::default(); - s.set_guides([0, 1]); - let p = paint(); - let c = cx(&d, &s, &p); - let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); - let mut g = GuideEdit::default(); - assert_eq!(g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, shift)), Some(vec![])); - let deselect = Action::Exec("guide.select".into(), json!({"indexes": [1], "toggle": true})); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, 400.0, shift)), Some(vec![deselect])); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, alt)); - let a = g.pointer(&c, &ev(PointerKind::Drag, 50.0, 420.0, alt)).unwrap(); - assert_eq!(a[0], Action::Begin("Copy Guide".into())); - assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true}))); - } - - /// #2143: a click on a guide selected with objects makes it the key; on the key guide again, - /// no key. Alone (no objects), with Shift, or dragged, it doesn't. - #[test] - fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { - // The same document as doc_with_rect's, with the guides. - let (d, rect) = (guides_doc(), doc_with_rect().1); - let p = paint(); - let click = |s: &Selection, m: Mods| { - let c = cx(&d, s, &p); - let mut g = GuideEdit::default(); - let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap(); - out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap()); - out - }; - let mut s = Selection::default(); - s.set([rect]); - s.toggle_guide(0); - let make = Action::Exec("select.key".into(), json!({"guide": 0})); - assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); - assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); - assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); - // Not selected yet: the first click selects it. - s.set([rect]); - assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); - // A drag moves it. - s.toggle_guide(0); - let c = cx(&d, &s, &p); - let mut g = GuideEdit::default(); - g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); - g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default())); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit])); - } - - #[test] - fn dropped_on_its_ruler_the_guide_is_deleted() { - let d = guides_doc(); - let p = paint(); - let mut s = Selection::default(); - s.set_guides([1]); - // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. - let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; - let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; - let m = Mods::default(); - let gesture = |to: Point, mods: Mods| { - let mut g = GuideEdit::default(); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); - g.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)); - g.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)).unwrap() - }; - assert_eq!(gesture(Point::new(50.0, -6.0), m), vec![Action::Cancel, Action::Exec("guide.remove".into(), json!({"index": 1}))]); - assert_eq!(gesture(Point::new(50.0, -6.0), Mods { alt: true, ..m }), vec![Action::Cancel], "a copy is just not made"); - // Off the left side: not its ruler. - assert_eq!(gesture(Point::new(-6.0, 300.0), m), vec![Action::Commit]); - // Headless (no window): no ruler to drop it on. - let mut g = GuideEdit::default(); - let c = cx(&d, &s, &p); - g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); - g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m)); - assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit])); - } - - // Tick positions are explicit fixtures in each unit, independent of the snap implementation. - fn origin_tick_cases() -> Vec<(vectorcraft_doc::Unit, f64, Point, Point, Point, f64)> { - use vectorcraft_doc::Unit; - let mm = |v| Unit::Millimeters.to_pt(v); - let inch = |v| Unit::Inches.to_pt(v); - vec![ - (Unit::Points, 1.0, Point::new(17.0, 23.0), Point::new(223.0, 237.0), Point::new(222.0, 238.0), 5.0), - (Unit::Points, 1.0, Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(223.0, 237.0), 5.0), - (Unit::Points, 5.0, Point::new(17.25, 23.75), Point::new(223.4, 237.2), Point::new(223.25, 236.75), 1.0), - (Unit::Millimeters, 1.0, Point::new(mm(1.0), mm(-1.0)), Point::new(mm(78.7), mm(83.6)), Point::new(mm(78.5), mm(84.0)), mm(2.5)), - ( - Unit::Inches, - 2.0, - Point::new(inch(0.01), inch(0.03)), - Point::new(inch(3.07), inch(3.27)), - // An inch ruler's ticks are binary fractions, so at zoom 2 they are 1/16 in - // apart, not the 0.05 in a decimal ladder gave (#2827). - Point::new(inch(3.0725), inch(3.28)), - inch(0.0625), - ), - ] - } - - fn guide_preview_position(actions: &[Action], vertical: bool, from: Option) -> f64 { - let Some(Action::Preview(command, p)) = actions.iter().find(|a| matches!(a, Action::Preview(_, _))) else { - panic!("missing guide preview: {actions:?}"); - }; - if let Some(from) = from { - assert_eq!(command, "guide.move"); - from + p[if vertical { "dx" } else { "dy" }].as_f64().unwrap() - } else { - assert_eq!(command, "guide.add"); - assert_eq!(p["vertical"], json!(vertical)); - p["pos"].as_f64().unwrap() - } - } - - #[test] - fn ruler_guide_shift_new_uses_document_origin() { - for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { - let (mut doc, _) = doc_with_rect(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let (selection, paint) = (Selection::default(), paint()); - let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for vertical in [false, true] { - let mut guide = NewGuide::new(&c, vertical, None); - let mods = Mods { shift: true, ..Default::default() }; - let actions = guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true); - assert_eq!(actions.first(), Some(&Action::Begin("New Guide".into()))); - let want = if vertical { expected.x } else { expected.y }; - let actual = guide_preview_position(&actions, vertical, None); - assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} actual={actual} want={want}"); - assert!(guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true).is_empty()); - // Release updates to its own tick rather than committing a stale preview. - let actions = guide.pointer(&c, &ev(PointerKind::Up, pointer.x + tick * 2.0, pointer.y + tick * 2.0, mods), true); - assert!((guide_preview_position(&actions, vertical, None) - (want + tick * 2.0)).abs() < 1e-8); - assert_eq!(actions.last(), Some(&Action::Commit)); - assert!(guide.overlays(&c).is_empty()); - } - } - } - - #[test] - fn ruler_guide_shift_move_uses_document_origin() { - for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for copy in [false, true] { - let mut guide = GuideEdit::default(); - let (start, to, want) = if vertical { - (Point::new(from, 50.0), Point::new(pointer.x, 50.0), expected.x) - } else { - (Point::new(50.0, from), Point::new(50.0, pointer.y), expected.y) - }; - assert!(guide.press(&c, &ev(PointerKind::Down, start.x, start.y, Mods::default())).unwrap().is_empty()); - let mods = Mods { shift: true, alt: copy, ..Default::default() }; - let actions = guide.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)).unwrap(); - assert_eq!(actions.first(), Some(&Action::Begin(if copy { "Copy Guide" } else { "Move Guide" }.into()))); - let actual = guide_preview_position(&actions, vertical, Some(from)); - assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} copy={copy} actual={actual} want={want}"); - assert!(matches!(actions.last(), Some(Action::Preview(_, p)) if p["copy"] == json!(copy))); - assert_eq!(guide.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)), Some(vec![Action::Commit])); - assert!(!guide.busy()); - } - } - } - } - - #[test] - fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { - // One bad axis invalidates the entire origin, matching the app's ruler display. - for origin in [ - Point::ZERO, - Point::new(f64::NAN, 23.0), - Point::new(17.0, f64::NAN), - Point::new(f64::INFINITY, 23.0), - Point::new(17.0, f64::NEG_INFINITY), - Point::new(4_000_001.0, 23.0), - Point::new(17.0, -4_000_001.0), - ] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - let mods = Mods { shift: true, ..Default::default() }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, 223.0, 237.0, mods), true); - assert_eq!(guide_preview_position(&actions, vertical, None), want); - let mut moved = GuideEdit::default(); - let (start, to) = if vertical { ((from, 50.0), (223.0, 50.0)) } else { ((50.0, from), (50.0, 237.0)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, mods)).unwrap(); - assert_eq!(guide_preview_position(&actions, vertical, Some(from)), want); - } - } - } - - #[test] - fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { - for origin in [Point::ZERO, Point::new(17.0, 23.0), Point::new(-17.0, -23.0)] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - for (c, pointer, expected) in [ - (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), - (ToolContext { snap_to_pixel: true, ..off }, Point::new(226.4, 237.6), Point::new(226.0, 238.0)), - (ToolContext { snap_to_grid: true, ..off }, Point::new(226.4, 237.6), Point::new(225.0, 234.0)), - (ToolContext { smart_guides: true, ..off }, Point::new(203.0, 102.0), Point::new(200.0, 100.0)), - (ToolContext { snap_to_point: true, ..off }, Point::new(201.0, 102.0), Point::new(200.0, 100.0)), - ] { - let want = if vertical { expected.x } else { expected.y }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); - assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); - let mut moved = GuideEdit::default(); - let (start, to) = - if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); - assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); - } - } - } - } - - #[test] - fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { - for (origin, expected) in [ - (Point::new(17.0, 23.0), Point::new(205.0, 215.0)), - (Point::new(-17.0, -23.0), Point::new(239.0, 261.0)), - (Point::ZERO, Point::new(222.0, 238.0)), - (Point::new(f64::NAN, 23.0), Point::new(222.0, 238.0)), - (Point::new(17.0, 4_000_001.0), Point::new(222.0, 238.0)), - ] { - let mut doc = guides_doc(); - doc.ruler_origin = origin; - doc.rulers = vectorcraft_doc::RulerMode::Global; - let paint = paint(); - for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { - for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { - let mut selection = Selection::default(); - selection.set_guides([index]); - let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); - let check = |overlays: Vec| { - assert!( - matches!(overlays.last(), Some(Overlay::Measure { text, .. }) if text == &want), - "{origin:?} {unit:?}: {overlays:?}, want {want}" - ); - }; - let mut new = NewGuide::new(&c, vertical, None); - let actions = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); - assert_eq!( - guide_preview_position(&actions, vertical, None), - if vertical { 222.0 } else { 238.0 }, - "commands keep absolute coordinates" - ); - check(new.overlays(&c)); - for copy in [false, true] { - let mut moved = GuideEdit::default(); - let (start, to) = if vertical { ((from, 50.0), (222.0, 50.0)) } else { ((50.0, from), (50.0, 238.0)) }; - moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); - moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods { alt: copy, ..Default::default() })).unwrap(); - check(moved.overlays(&c)); - } - let quiet = ToolContext { measurement_labels: false, ..c }; - assert!(new.overlays(&quiet).is_empty(), "measurement-label preference is preserved"); - } - } - } - } - - #[test] - fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { - let mut doc = guides_doc(); - doc.artboards.push(vectorcraft_doc::Artboard { - id: 2, - name: "Artboard 2".into(), - rect: Rect::new(150.0, 200.0, 350.0, 400.0), - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }); - doc.ruler_origin = Point::new(5.0, 5.0); - let (paint, selection) = (paint(), Selection::default()); - for (rulers, active, zero) in [ - (vectorcraft_doc::RulerMode::Artboard, 1, Point::new(150.0, 200.0)), - (vectorcraft_doc::RulerMode::Artboard, 0, Point::new(doc.artboards[0].rect.x0, doc.artboards[0].rect.y0)), - (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), - ] { - doc.rulers = rulers; - let c = - ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; - assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); - for vertical in [true, false] { - let mut new = NewGuide::new(&c, vertical, None); - new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); - let want = if vertical { format!("X: {}", c.len(222.0 - zero.x)) } else { format!("Y: {}", c.len(238.0 - zero.y)) }; - assert!(matches!(new.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &want), "{rulers:?} {active}: want {want}"); - // Shift puts the guide on a ruler tick counted from the same zero point. - let snapped = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods { shift: true, ..Default::default() }), true); - let at = guide_preview_position(&snapped, vertical, None); - let from = if vertical { zero.x } else { zero.y }; - assert_eq!(at, from + c.unit.snap_to_ruler_tick(if vertical { 222.0 } else { 238.0 } - from, c.zoom), "{rulers:?} {active}"); - } - } - } -} +mod tests; diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs new file mode 100644 index 000000000..67b37412b --- /dev/null +++ b/crates/tools/src/rulerguide/tests.rs @@ -0,0 +1,602 @@ +use super::*; +use crate::testutil::*; +use crate::{Mods, ScreenFrame}; +use vectorcraft_doc::{Guide, Selection}; +use vectorcraft_geom::Vec2; + +fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { + PointerEvent::new(kind, x, y).with_mods(mods) +} + +fn guides_doc() -> vectorcraft_doc::Document { + let (mut d, _) = doc_with_rect(); + d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d +} + +#[test] +fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { + let (d, s, p) = (guides_doc(), Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert_eq!(guide_at(&c, Point::new(302.0, 50.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(50.0, 398.0)), Some(1)); + // Where they cross, the nearer one. + assert_eq!(guide_at(&c, Point::new(301.0, 398.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(305.0, 50.0)), None, "beyond the 3 px tolerance"); + let hidden = ToolContext { guides: false, ..cx(&d, &s, &p) }; + assert_eq!(guide_at(&hidden, Point::new(300.0, 50.0)), None); +} + +#[test] +fn a_drag_moves_the_selected_guides_in_one_step() { + let d = guides_doc(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut g = GuideEdit::default(); + let m = Mods::default(); + assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); + // The tool sees the guide selected from now on. + let mut s = s; + s.set_guides([0]); + let c = cx(&d, &s, &p); + let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); + assert_eq!(a[0], Action::Begin("Move Guide".into())); + assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 40.0, "dy": 10.0, "copy": false}))); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "X: 340.00 pt")); + assert_eq!(g.cursor(&c, Point::ZERO), Some(Cursor::ResizeH)); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 60.0, m)), Some(vec![Action::Commit])); + assert!(!g.busy() && g.pointer(&c, &ev(PointerKind::Drag, 0.0, 0.0, m)).is_none()); +} + +#[test] +fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0]); + let p = paint(); + let drag_to = |c: &ToolContext, x: f64, mods: Mods| { + let mut g = GuideEdit::default(); + g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); + let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, mods)).unwrap(); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + (300.0 + v["dx"].as_f64().unwrap(), g.overlays(c)) + }; + // Smart Guides: onto the rect's right edge (x = 200), labelled. + let (x, ov) = drag_to(&cx(&d, &s, &p), 203.0, Mods::default()); + assert_eq!(x, 200.0); + assert!(matches!(&ov[0], Overlay::Label { .. })); + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&off, 203.0, Mods::default()).0, 203.0); + // Shift: the ruler's ticks (labels every 50 pt at 100 %, ticks every 5 pt). + assert_eq!(drag_to(&off, 223.0, Mods { shift: true, ..Default::default() }).0, 225.0); + let zoomed = ToolContext { zoom: 5.0, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&zoomed, 223.4, Mods { shift: true, ..Default::default() }).0, 223.0, "every point at 500 %"); + let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions"); +} + +/// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its +/// centre's line) and anchors, and on the artboard's edges and centre, labelled. +#[test] +fn a_new_guide_snaps_to_the_art_and_the_artboard() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let m = Mods::default(); + let drag = |vertical: bool, v: f64| { + let mut g = NewGuide::new(&c, vertical, None); + let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) }; + let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true); + let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") }; + assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical))); + let label = g.overlays(&c).iter().find_map(|o| match o { + Overlay::Label { text, .. } => Some(text.clone()), + _ => None, + }); + (p["pos"].as_f64().unwrap(), label) + }; + assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints"); + assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners"); + assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge"); + assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre"); + assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach"); +} + +/// Preferences › Smart Guides (#394) reach a guide dragged out of a ruler: Snapping +/// Tolerance sets how far a target pulls it, Anchor/Path Labels and Measurement Labels what +/// shows by it. +#[test] +fn a_new_guide_follows_the_smart_guide_preferences() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let drag = |c: &ToolContext| { + let mut g = NewGuide::new(c, true, None); + let a = g.pointer(c, &ev(PointerKind::Drag, 497.0, 50.0, Mods::default()), true); + let Some(Action::Preview(_, p)) = a.last() else { panic!("{a:?}") }; + (p["pos"].as_f64().unwrap(), g.overlays(c)) + }; + let (at, ov) = drag(&ToolContext { snapping_tolerance: 2.0, ..cx(&d, &s, &p) }); + assert_eq!((at, ov.len()), (497.0, 1), "3 px off is beyond 2: only the readout {ov:?}"); + let (at, ov) = drag(&ToolContext { anchor_path_labels: false, measurement_labels: false, ..cx(&d, &s, &p) }); + assert_eq!((at, ov), (500.0, vec![]), "onto the artboard's edge, nothing said"); +} + +#[test] +fn a_new_guide_is_made_on_release_over_the_canvas_only() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let m = Mods::default(); + let mut g = NewGuide::new(&c, false, Some(0)); + let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0})); + // Off the canvas (still over the ruler): nothing yet. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt")); + // The same place again: no new preview. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]); + // Back onto the ruler it goes, and comes back over the canvas. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]); + assert!(g.overlays(&c).is_empty()); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]); + // Shift: the ruler's ticks. + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]); + // Released off the canvas: none. + let mut g = NewGuide::new(&c, true, None); + g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]); +} + +#[test] +fn new_ruler_guide_alt_switches_axis_and_uses_the_release_position() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + for source_vertical in [false, true] { + let mut g = NewGuide::new(&c, source_vertical, Some(0)); + for (i, alt) in [false, true, false, true].into_iter().enumerate() { + let vertical = source_vertical != alt; + let at = if vertical { 80.0 } else { 120.0 }; + let mut expected = vec![]; + if i == 0 { + expected.push(Action::Begin("New Guide".into())); + } + expected.push(Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": at, "artboard": 0}))); + assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 80.0, 120.0, Mods { alt, ..Default::default() }), true), expected); + let label = format!("{}: {at:.2} pt", if vertical { "X" } else { "Y" }); + assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &label)); + } + // Releasing at a new position with Alt released uses both final coordinates and modifiers. + let at = if source_vertical { 190.0 } else { 240.0 }; + assert_eq!( + g.pointer(&c, &ev(PointerKind::Up, 190.0, 240.0, Mods::default()), true), + vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical, "pos": at, "artboard": 0})), Action::Commit] + ); + assert!(g.overlays(&c).is_empty()); + } +} + +#[test] +fn new_ruler_guide_alt_switches_even_when_coordinates_are_equal() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + for source_vertical in [false, true] { + let mut g = NewGuide::new(&c, source_vertical, None); + g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods::default()), true); + for alt in [true, false] { + assert_eq!( + g.pointer(&c, &ev(PointerKind::Drag, 80.0, 80.0, Mods { alt, ..Default::default() }), true), + vec![Action::Preview("guide.add".into(), json!({"vertical": source_vertical != alt, "pos": 80.0}))] + ); + } + } +} + +#[test] +fn new_ruler_guide_alt_snaps_on_the_effective_axis() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let grid = ToolContext { snap_to_grid: true, ..off }; + let pixel = ToolContext { snap_to_pixel: true, ..off }; + let smart = cx(&d, &s, &p); + for (c, shift, x, y, expected_x, expected_y) in [ + (&off, true, 223.0, 237.0, 225.0, 235.0), + (&grid, false, 226.0, 237.0, 225.0, 234.0), + (&pixel, false, 226.4, 237.6, 226.0, 238.0), + (&smart, false, 203.0, 102.0, 200.0, 100.0), + ] { + for source_vertical in [false, true] { + let mut g = NewGuide::new(c, source_vertical, None); + let actions = g.pointer(c, &ev(PointerKind::Drag, x, y, Mods { alt: true, shift, ..Default::default() }), true); + let expected = if source_vertical { expected_y } else { expected_x }; + assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": !source_vertical, "pos": expected})))); + } + } +} + +/// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its +/// distance), not the edges' midpoints. +#[test] +fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0]); + let p = paint(); + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + let moved_to = |c: &ToolContext, x: f64| { + let mut g = GuideEdit::default(); + g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default())); + let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap(); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + 300.0 + v["dx"].as_f64().unwrap() + }; + assert_eq!(moved_to(&off, 201.5), 200.0); + assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor"); + assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance"); + let neither = ToolContext { snap_to_point: false, ..off }; + assert_eq!(moved_to(&neither, 201.5), 201.5); + let mut g = NewGuide::new(&off, true, None); + let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true); + assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0})))); +} + +/// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a +/// point with Snap to Point there. +#[test] +fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { + let (mut d, _) = doc_with_rect(); + let id = d.next_artboard_id(); + d.artboards.push(vectorcraft_doc::Artboard { + id, + name: "B".into(), + rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); + assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end"); + assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard"); + let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap(); + assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0)); + assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5)); +} + +#[test] +fn shift_click_toggles_and_alt_drag_copies() { + let d = guides_doc(); + let mut s = Selection::default(); + s.set_guides([0, 1]); + let p = paint(); + let c = cx(&d, &s, &p); + let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); + let mut g = GuideEdit::default(); + assert_eq!(g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, shift)), Some(vec![])); + let deselect = Action::Exec("guide.select".into(), json!({"indexes": [1], "toggle": true})); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, 400.0, shift)), Some(vec![deselect])); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, alt)); + let a = g.pointer(&c, &ev(PointerKind::Drag, 50.0, 420.0, alt)).unwrap(); + assert_eq!(a[0], Action::Begin("Copy Guide".into())); + assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true}))); +} + +/// #2143: a click on a guide selected with objects makes it the key; on the key guide again, +/// no key. Alone (no objects), with Shift, or dragged, it doesn't. +#[test] +fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { + // The same document as doc_with_rect's, with the guides. + let (d, rect) = (guides_doc(), doc_with_rect().1); + let p = paint(); + let click = |s: &Selection, m: Mods| { + let c = cx(&d, s, &p); + let mut g = GuideEdit::default(); + let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap(); + out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap()); + out + }; + let mut s = Selection::default(); + s.set([rect]); + s.toggle_guide(0); + let make = Action::Exec("select.key".into(), json!({"guide": 0})); + assert_eq!(click(&s, Mods::default()), vec![make]); + s.key_guide = Some(0); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); + s.set_guides([0]); + assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); + // Not selected yet: the first click selects it. + s.set([rect]); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); + // A drag moves it. + s.toggle_guide(0); + let c = cx(&d, &s, &p); + let mut g = GuideEdit::default(); + g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); + g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default())); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit])); +} + +#[test] +fn dropped_on_its_ruler_the_guide_is_deleted() { + let d = guides_doc(); + let p = paint(); + let mut s = Selection::default(); + s.set_guides([1]); + // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. + let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; + let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; + let m = Mods::default(); + let gesture = |to: Point, mods: Mods| { + let mut g = GuideEdit::default(); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); + g.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)); + g.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)).unwrap() + }; + assert_eq!(gesture(Point::new(50.0, -6.0), m), vec![Action::Cancel, Action::Exec("guide.remove".into(), json!({"index": 1}))]); + assert_eq!(gesture(Point::new(50.0, -6.0), Mods { alt: true, ..m }), vec![Action::Cancel], "a copy is just not made"); + // Off the left side: not its ruler. + assert_eq!(gesture(Point::new(-6.0, 300.0), m), vec![Action::Commit]); + // Headless (no window): no ruler to drop it on. + let mut g = GuideEdit::default(); + let c = cx(&d, &s, &p); + g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m)); + g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m)); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit])); +} + +// Tick positions are explicit fixtures in each unit, independent of the snap implementation. +fn origin_tick_cases() -> Vec<(vectorcraft_doc::Unit, f64, Point, Point, Point, f64)> { + use vectorcraft_doc::Unit; + let mm = |v| Unit::Millimeters.to_pt(v); + let inch = |v| Unit::Inches.to_pt(v); + vec![ + (Unit::Points, 1.0, Point::new(17.0, 23.0), Point::new(223.0, 237.0), Point::new(222.0, 238.0), 5.0), + (Unit::Points, 1.0, Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(223.0, 237.0), 5.0), + (Unit::Points, 5.0, Point::new(17.25, 23.75), Point::new(223.4, 237.2), Point::new(223.25, 236.75), 1.0), + (Unit::Millimeters, 1.0, Point::new(mm(1.0), mm(-1.0)), Point::new(mm(78.7), mm(83.6)), Point::new(mm(78.5), mm(84.0)), mm(2.5)), + ( + Unit::Inches, + 2.0, + Point::new(inch(0.01), inch(0.03)), + Point::new(inch(3.07), inch(3.27)), + // An inch ruler's ticks are binary fractions, so at zoom 2 they are 1/16 in + // apart, not the 0.05 in a decimal ladder gave (#2827). + Point::new(inch(3.0725), inch(3.28)), + inch(0.0625), + ), + ] +} + +fn guide_preview_position(actions: &[Action], vertical: bool, from: Option) -> f64 { + let Some(Action::Preview(command, p)) = actions.iter().find(|a| matches!(a, Action::Preview(_, _))) else { + panic!("missing guide preview: {actions:?}"); + }; + if let Some(from) = from { + assert_eq!(command, "guide.move"); + from + p[if vertical { "dx" } else { "dy" }].as_f64().unwrap() + } else { + assert_eq!(command, "guide.add"); + assert_eq!(p["vertical"], json!(vertical)); + p["pos"].as_f64().unwrap() + } +} + +#[test] +fn ruler_guide_shift_new_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { + let (mut doc, _) = doc_with_rect(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let (selection, paint) = (Selection::default(), paint()); + let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for vertical in [false, true] { + let mut guide = NewGuide::new(&c, vertical, None); + let mods = Mods { shift: true, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true); + assert_eq!(actions.first(), Some(&Action::Begin("New Guide".into()))); + let want = if vertical { expected.x } else { expected.y }; + let actual = guide_preview_position(&actions, vertical, None); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} actual={actual} want={want}"); + assert!(guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true).is_empty()); + // Release updates to its own tick rather than committing a stale preview. + let actions = guide.pointer(&c, &ev(PointerKind::Up, pointer.x + tick * 2.0, pointer.y + tick * 2.0, mods), true); + assert!((guide_preview_position(&actions, vertical, None) - (want + tick * 2.0)).abs() < 1e-8); + assert_eq!(actions.last(), Some(&Action::Commit)); + assert!(guide.overlays(&c).is_empty()); + } + } +} + +#[test] +fn ruler_guide_shift_move_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for copy in [false, true] { + let mut guide = GuideEdit::default(); + let (start, to, want) = if vertical { + (Point::new(from, 50.0), Point::new(pointer.x, 50.0), expected.x) + } else { + (Point::new(50.0, from), Point::new(50.0, pointer.y), expected.y) + }; + assert!(guide.press(&c, &ev(PointerKind::Down, start.x, start.y, Mods::default())).unwrap().is_empty()); + let mods = Mods { shift: true, alt: copy, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)).unwrap(); + assert_eq!(actions.first(), Some(&Action::Begin(if copy { "Copy Guide" } else { "Move Guide" }.into()))); + let actual = guide_preview_position(&actions, vertical, Some(from)); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} copy={copy} actual={actual} want={want}"); + assert!(matches!(actions.last(), Some(Action::Preview(_, p)) if p["copy"] == json!(copy))); + assert_eq!(guide.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)), Some(vec![Action::Commit])); + assert!(!guide.busy()); + } + } + } +} + +#[test] +fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { + // One bad axis invalidates the entire origin, matching the app's ruler display. + for origin in [ + Point::ZERO, + Point::new(f64::NAN, 23.0), + Point::new(17.0, f64::NAN), + Point::new(f64::INFINITY, 23.0), + Point::new(17.0, f64::NEG_INFINITY), + Point::new(4_000_001.0, 23.0), + Point::new(17.0, -4_000_001.0), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let mods = Mods { shift: true, ..Default::default() }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 223.0, 237.0, mods), true); + assert_eq!(guide_preview_position(&actions, vertical, None), want); + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (223.0, 50.0)) } else { ((50.0, from), (50.0, 237.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, mods)).unwrap(); + assert_eq!(guide_preview_position(&actions, vertical, Some(from)), want); + } + } +} + +#[test] +fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { + for origin in [Point::ZERO, Point::new(17.0, 23.0), Point::new(-17.0, -23.0)] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for (c, pointer, expected) in [ + (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), + (ToolContext { snap_to_pixel: true, ..off }, Point::new(226.4, 237.6), Point::new(226.0, 238.0)), + (ToolContext { snap_to_grid: true, ..off }, Point::new(226.4, 237.6), Point::new(225.0, 234.0)), + (ToolContext { smart_guides: true, ..off }, Point::new(203.0, 102.0), Point::new(200.0, 100.0)), + (ToolContext { snap_to_point: true, ..off }, Point::new(201.0, 102.0), Point::new(200.0, 100.0)), + ] { + let want = if vertical { expected.x } else { expected.y }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); + assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); + let mut moved = GuideEdit::default(); + let (start, to) = + if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); + assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); + } + } + } +} + +#[test] +fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { + for (origin, expected) in [ + (Point::new(17.0, 23.0), Point::new(205.0, 215.0)), + (Point::new(-17.0, -23.0), Point::new(239.0, 261.0)), + (Point::ZERO, Point::new(222.0, 238.0)), + (Point::new(f64::NAN, 23.0), Point::new(222.0, 238.0)), + (Point::new(17.0, 4_000_001.0), Point::new(222.0, 238.0)), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; + let paint = paint(); + for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); + let check = |overlays: Vec| { + assert!( + matches!(overlays.last(), Some(Overlay::Measure { text, .. }) if text == &want), + "{origin:?} {unit:?}: {overlays:?}, want {want}" + ); + }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + assert_eq!( + guide_preview_position(&actions, vertical, None), + if vertical { 222.0 } else { 238.0 }, + "commands keep absolute coordinates" + ); + check(new.overlays(&c)); + for copy in [false, true] { + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (222.0, 50.0)) } else { ((50.0, from), (50.0, 238.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods { alt: copy, ..Default::default() })).unwrap(); + check(moved.overlays(&c)); + } + let quiet = ToolContext { measurement_labels: false, ..c }; + assert!(new.overlays(&quiet).is_empty(), "measurement-label preference is preserved"); + } + } + } +} + +#[test] +fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { + let mut doc = guides_doc(); + doc.artboards.push(vectorcraft_doc::Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(150.0, 200.0, 350.0, 400.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + doc.ruler_origin = Point::new(5.0, 5.0); + let (paint, selection) = (paint(), Selection::default()); + for (rulers, active, zero) in [ + (vectorcraft_doc::RulerMode::Artboard, 1, Point::new(150.0, 200.0)), + (vectorcraft_doc::RulerMode::Artboard, 0, Point::new(doc.artboards[0].rect.x0, doc.artboards[0].rect.y0)), + (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), + ] { + doc.rulers = rulers; + let c = + ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); + for vertical in [true, false] { + let mut new = NewGuide::new(&c, vertical, None); + new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + let want = if vertical { format!("X: {}", c.len(222.0 - zero.x)) } else { format!("Y: {}", c.len(238.0 - zero.y)) }; + assert!(matches!(new.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &want), "{rulers:?} {active}: want {want}"); + // Shift puts the guide on a ruler tick counted from the same zero point. + let snapped = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods { shift: true, ..Default::default() }), true); + let at = guide_preview_position(&snapped, vertical, None); + let from = if vertical { zero.x } else { zero.y }; + assert_eq!(at, from + c.unit.snap_to_ruler_tick(if vertical { 222.0 } else { 238.0 } - from, c.zoom), "{rulers:?} {active}"); + } + } +}