mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #1918 from sanyamsingh001/codex/r65-guide-origin
G1: align guide snapping and readouts with ruler origin
This commit is contained in:
@@ -123,6 +123,9 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
/// Coordinates beyond this many points exceed the supported document range.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("no such object {0}")]
|
||||
@@ -749,6 +752,13 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
let origin = self.ruler_origin;
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
|
||||
}
|
||||
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
|
||||
@@ -546,7 +546,7 @@ impl DocState {
|
||||
}
|
||||
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
pub use vectorcraft_doc::MAX_COORD;
|
||||
|
||||
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
|
||||
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
|
||||
|
||||
@@ -112,7 +112,9 @@ impl GuideSnap {
|
||||
fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 {
|
||||
self.snapped.clear();
|
||||
if shift {
|
||||
return cx.unit.snap_to_ruler_tick(v, cx.zoom);
|
||||
let origin = cx.doc.ruler_origin_or_zero();
|
||||
let origin = if vertical { origin.x } else { origin.y };
|
||||
return origin + cx.unit.snap_to_ruler_tick(v - origin, cx.zoom);
|
||||
}
|
||||
if cx.snap_to_pixel {
|
||||
return v.round();
|
||||
@@ -130,8 +132,9 @@ impl GuideSnap {
|
||||
fn overlays(&self, cx: &ToolContext, vertical: bool, at: f64, pointer: Point) -> Vec<Overlay> {
|
||||
let mut o = self.snapped.clone();
|
||||
if cx.measurement_labels {
|
||||
let axis = if vertical { "X" } else { "Y" };
|
||||
o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at)) });
|
||||
let origin = cx.doc.ruler_origin_or_zero();
|
||||
let (axis, zero) = if vertical { ("X", origin.x) } else { ("Y", origin.y) };
|
||||
o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at - zero)) });
|
||||
}
|
||||
o
|
||||
}
|
||||
@@ -631,4 +634,205 @@ mod tests {
|
||||
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)),
|
||||
Point::new(inch(3.06), inch(3.28)),
|
||||
inch(0.05),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5018,4 +5018,197 @@ mod tests {
|
||||
near(select(&mut app, &a), under_a);
|
||||
near(select(&mut app, &b), under_b);
|
||||
}
|
||||
|
||||
fn ruler_guide_origin_ui(alt: bool, cancel: bool) {
|
||||
use egui::{Event, Modifiers};
|
||||
for (origin, preview, release) in [
|
||||
(Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)),
|
||||
(Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)),
|
||||
(Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)),
|
||||
] {
|
||||
for source_vertical in [false, true] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap();
|
||||
let redo = app.session.active().unwrap().doc.clone();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.session.active_mut().unwrap().mark_saved();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.smart_guides = false;
|
||||
app.ui.view.snap_to_point = false;
|
||||
app.ui.view.snap_to_grid = false;
|
||||
app.ui.view.snap_to_pixel = false;
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 1.0;
|
||||
view.rotation = 0.0;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let from =
|
||||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||||
let to = xf.to_screen(Point::new(223.0, 237.0));
|
||||
let end = xf.to_screen(Point::new(231.0, 244.0));
|
||||
assert!(rect.contains(to) && rect.contains(end));
|
||||
let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT };
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods };
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let selection = app.session.active().unwrap().selection.clone();
|
||||
let history = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let original_history = history(&app);
|
||||
assert_eq!(original_history.1, 1);
|
||||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]);
|
||||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
let check = |app: &VectorcraftApp, switched: bool, expected: Point| {
|
||||
let guides = &app.session.active().unwrap().doc.guides;
|
||||
assert_eq!(guides.len(), 1);
|
||||
let vertical = source_vertical != switched;
|
||||
assert_eq!(guides[0].vertical, vertical);
|
||||
let want = if vertical { expected.x } else { expected.y };
|
||||
assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]);
|
||||
};
|
||||
assert_eq!(history(&app).0, original_history.0, "preview has no undo entry");
|
||||
if cancel {
|
||||
// Cancellation is a baseline positive control, independent of snap correctness.
|
||||
assert_eq!(app.session.active().unwrap().doc.guides.len(), 1);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert_eq!(app.session.active().unwrap().selection, selection);
|
||||
assert_eq!(history(&app), original_history);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, redo);
|
||||
continue;
|
||||
}
|
||||
check(&app, alt, preview);
|
||||
if alt {
|
||||
// Used only by the private #1782 compatibility regression, not standalone RED.
|
||||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]);
|
||||
check(&app, false, preview);
|
||||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]);
|
||||
check(&app, true, preview);
|
||||
}
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||||
check(&app, alt, release);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.ruler_origin, origin);
|
||||
assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork");
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!(history(&app), (original_history.0 + 1, 0, true));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "New Guide");
|
||||
let after = st.doc.clone();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
assert_eq!(history(&app), (original_history.0, 1, false));
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guide_shift_origin_preview_release_and_history() {
|
||||
ruler_guide_origin_ui(false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guide_shift_origin_cancel_preserves_redo() {
|
||||
ruler_guide_origin_ui(false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let max = vectorcraft_engine::MAX_COORD;
|
||||
for (origin, expected) in [
|
||||
(Point::ZERO, Point::ZERO),
|
||||
(Point::new(17.0, -23.0), Point::new(17.0, -23.0)),
|
||||
(Point::new(max, -max), Point::new(max, -max)),
|
||||
(Point::new(f64::NAN, 23.0), Point::ZERO),
|
||||
(Point::new(17.0, f64::INFINITY), Point::ZERO),
|
||||
(Point::new(max + 1.0, 23.0), Point::ZERO),
|
||||
(Point::new(17.0, -max - 1.0), Point::ZERO),
|
||||
] {
|
||||
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin;
|
||||
assert_eq!(app.ruler_origin(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guide_origin_tooltip_matches_selected_guide_fields() {
|
||||
use egui::Event;
|
||||
for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap();
|
||||
app.ui.view.guides = true;
|
||||
app.ui.view.smart_guides = false;
|
||||
app.ui.view.snap_to_point = false;
|
||||
app.ui.view.snap_to_grid = false;
|
||||
app.ui.view.snap_to_pixel = false;
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 1.0;
|
||||
view.rotation = 0.0;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.session.active_mut().unwrap().mark_saved();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) });
|
||||
let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) });
|
||||
assert!(rect.contains(from) && rect.contains(to));
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4);
|
||||
assert_eq!(app.session.active().unwrap().selection.guides, vec![0]);
|
||||
// Observe the real selected-guide field; no expected strings are inserted into UI state.
|
||||
let fields = egui::Context::default();
|
||||
crate::theme::install_fonts(&fields);
|
||||
let mut texts = vec![];
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() };
|
||||
let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
fn collect(shape: &egui::Shape, texts: &mut Vec<String>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => texts.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
texts.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts));
|
||||
}
|
||||
assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}");
|
||||
let view = app.view_info();
|
||||
let overlays = app.session.overlays(view);
|
||||
let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" });
|
||||
assert!(
|
||||
overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)),
|
||||
"{overlays:?}, field={shown}"
|
||||
);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,13 +618,7 @@ impl VectorcraftApp {
|
||||
|
||||
/// The zero point shown by rulers and position fields; command coordinates stay absolute.
|
||||
pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point {
|
||||
let origin = self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin);
|
||||
// An untrusted file cannot make the ruler's tick loop or position fields overflow.
|
||||
if origin.is_finite() && origin.x.abs() <= vectorcraft_engine::MAX_COORD && origin.y.abs() <= vectorcraft_engine::MAX_COORD {
|
||||
origin
|
||||
} else {
|
||||
vectorcraft_geom::Point::ZERO
|
||||
}
|
||||
self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_or_zero())
|
||||
}
|
||||
pub fn view_mut(&mut self) -> Option<&mut View> {
|
||||
self.sync_views();
|
||||
|
||||
Reference in New Issue
Block a user