Files
vectorcraft/crates/tools/src/rulerguide/tests.rs
T
dotCooCoo beef32b8a3 Artboards: an artboard's guide label colors its own guides
Each artboard can carry a guide label: one of the layer colors or a
custom RGB. Guides that belong to an artboard draw in its label color,
and guides without an artboard, or on an artboard without a label, keep
the guide color from the preferences. Selected guides still draw in the
selection color.

- Artboard Options has a Guide Label row (None, the layer colors with
  swatches, or Custom with a color swatch).
- artboard.new and artboard.setProps take guideLabel: null, a layer
  color's name, or #rrggbb. Any other value is an error. The inspect
  summary lists guideLabel when one is set.
- The native format saves the label and skips it when unset; a preset
  index outside the layer colors reads as no label.
- artboard.setProps no longer adds an undo step when nothing changes.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-10 19:37:17 -08:00

633 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]);
}
/// A guide made only within its artboard ([`NewGuide::within`]) shows and is made where its
/// position, on the axis Alt gives it, lies within the artboard (edges included). Outside it, or
/// with an artboard the document doesn't have, it goes as it does off the canvas.
#[test]
fn a_guide_within_its_artboard_is_made_only_inside_it() {
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 alt = Mods { alt: true, ..m };
let preview = |vertical: bool, pos: f64| Action::Preview("guide.add".into(), json!({"vertical": vertical, "pos": pos, "artboard": 0}));
let mut g = NewGuide::within(&c, true, 0);
// Right of the 500 pt artboard: nothing, then its right edge.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 520.0, 40.0, m), true), vec![]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 500.0, 40.0, m), true), vec![Action::Begin("New Guide".into()), preview(true, 500.0)]);
// Alt makes it horizontal: y 40 is inside, y 600 is not.
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 700.0, 40.0, alt), true), vec![preview(false, 40.0)]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 300.0, 600.0, alt), true), vec![Action::Cancel]);
assert!(g.overlays(&c).is_empty());
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 300.0, 600.0, alt), true), vec![]);
// Released inside: made.
let mut g = NewGuide::within(&c, false, 0);
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 900.0, 0.0, m), true), vec![Action::Begin("New Guide".into()), preview(false, 0.0)]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 900.0, 0.0, m), true), vec![Action::Commit]);
// No artboard 3: never shown.
let mut g = NewGuide::within(&c, true, 3);
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 100.0, 40.0, m), true), vec![]);
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 100.0, 40.0, m), true), vec![]);
}
#[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,
guide_label: 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>) -> 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<Overlay>| {
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,
guide_label: 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}");
}
}
}