Files
vectorcraft/crates/ui-egui/src/tests_ruler_origin.rs
T
VictorandClaude Opus 5.5 276eeacae6 G7: View › Rulers › Change to Global / Artboard Rulers
View › Rulers › Change to Global Rulers (Cmd+Alt+R) was a disabled
stub: the rulers always counted from one document zero point. The
reference app's public help describes two modes: artboard rulers, the
default, count from the active artboard's top-left corner, and each
artboard can have its own zero point; global rulers keep one zero point
for the whole document whichever artboard is active.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html

`view.globalRulers {global?}` switches the mode (toggle by default),
undoable and saved with the document (`rulers`). The menu item reads
Change to Artboard Rulers while global rulers are on. A new document has
artboard rulers; a file saved before the mode existed has no `rulers`
key and opens with global rulers, so it measures as it did.

With artboard rulers, dragging the ruler corner sets the active
artboard's zero point, kept as an offset from its corner
(`artboard_ruler_origins`, by artboard id) so it moves with the
artboard, and double-clicking the corner resets it. The global zero
point (`ruler_origin`) stays separate. `document.rulerOrigin`,
`document.setRulerOrigin` and `document.resetRulerOrigin` take
`artboard`; `Document::ruler_origin_for` is the zero point with an
artboard active, falling back to the corner or the global one when a
stored value is out of range.

The rulers, the Transform, Properties and Control bar X/Y, the Info
panel's X/Y (which ignored the zero point before), ruler-guide
readouts and Shift ruler-tick snapping all count from that zero point:
the view's active artboard reaches the tools through `ViewInfo` and
`ToolContext::ruler_origin`. Command coordinates stay absolute.

Tests: engine/tests/ruler_origin.rs (default mode, per-artboard origin
following its artboard, global origin apart, toggle and undo, save and
legacy files, out-of-range values), ui-egui/src/tests_ruler_origin.rs
(position fields and corner drag on the active artboard, menu label and
shortcut), tools/src/rulerguide.rs (guide readouts and tick snapping).
Tests written for the single global zero point now turn global rulers
on first. "Change to Artboard Rulers" is translated in every catalog.

Docs: docs/ui-parity.md, docs/gaps.md (G7), docs/target-app-parity.md,
docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2JbUtM4MkzWLm9FgYiEsq
2026-10-17 16:38:41 +07:00

287 lines
14 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.
//! #1212: real ruler gestures and position fields share the document's zero point.
use egui::{Event, Key, PointerButton, Pos2, Rect, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::{NodeId, Unit};
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, chrome, panels};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.view.rulers = true;
app.ui.control_bar = true;
app
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
}
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0))),
time: Some(ctx.input(|i| i.time) + 0.05),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
texts
}
fn press(at: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn key(key: Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
fn start_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: Pos2) {
let at = pos2(8.0, 8.0);
frame(app, ctx, vec![Event::PointerMoved(at)], canvas::show);
frame(app, ctx, vec![press(at, true)], canvas::show);
frame(app, ctx, vec![Event::PointerMoved(to)], canvas::show);
}
#[test]
fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() {
let mut app = app();
app.select_tool("rectangle");
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
let v = app.view_mut().unwrap();
v.center = Point::new(220.0, 130.0);
v.zoom = 2.3;
v.rotation = 35.0;
frame(&mut app, &ctx, vec![], canvas::show);
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let goal = Point::new(125.25, 90.5);
let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(goal);
start_drag(&mut app, &ctx, to);
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "preview does not edit the document");
frame(&mut app, &ctx, vec![press(to, false)], canvas::show);
let st = app.session.doc().unwrap();
assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0));
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag");
assert!(st.doc.guides.is_empty());
let origin = st.doc.ruler_origin_for(0);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.ruler_origin(), origin);
}
#[test]
fn ruler_origin_double_click_resets_to_the_active_artboard() {
let mut app = app();
app.run("view.globalRulers", json!({"global": true})).unwrap();
app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
app.run("document.setRulerOrigin", json!({"x": 125, "y": 90})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
app.view_mut().unwrap().artboard = 1;
let at = pos2(8.0, 8.0);
let undo = app.session.doc().unwrap().history.undo.len();
for _ in 0..2 {
frame(&mut app, &ctx, vec![Event::PointerMoved(at)], canvas::show);
frame(&mut app, &ctx, vec![press(at, true)], canvas::show);
frame(&mut app, &ctx, vec![press(at, false)], canvas::show);
}
assert_eq!(app.ruler_origin(), Point::new(700.0, -250.0));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.ruler_origin(), Point::new(125.0, 90.0));
}
#[test]
fn ruler_origin_cancelled_drags_do_not_modify_either_document() {
#[derive(Debug, PartialEq)]
enum Cancel {
Outside,
Escape,
Focus,
Document,
}
for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] {
let mut app = app();
app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
let to = app.canvas_rect.unwrap().center();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
start_drag(&mut app, &ctx, to);
let release = match cancel {
Cancel::Outside => pos2(4.0, 4.0),
Cancel::Escape => {
frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show);
to
}
Cancel::Focus => {
let mut out = ctx.run_ui(egui::RawInput { focused: false, ..Default::default() }, |ui| canvas::show(&mut app, ui));
out.textures_delta.clear();
to
}
Cancel::Document => {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
to
}
};
frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show);
if cancel == Cancel::Document {
assert_eq!(app.ruler_origin(), Point::ZERO);
app.run("document.activate", json!({"index": 0})).unwrap();
assert_eq!(&*app.session.doc().unwrap().doc, &*before);
} else {
assert_eq!(&*app.session.doc().unwrap().doc, &*before, "{cancel:?}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "{cancel:?}");
}
}
}
#[test]
fn ruler_origin_position_fields_show_relative_values_and_edit_absolute_positions() {
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show, chrome::control_bar] {
for (label, arrow) in [("X:", (1.0, 0.0)), ("Y:", (0.0, 1.0))] {
let mut app = app();
let id = NodeId(app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap());
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], draw);
let texts = frame(&mut app, &ctx, vec![], draw);
assert!(texts.iter().filter(|(t, _)| t == &Unit::Points.format(20.0)).count() >= 2, "{texts:?}");
let label = texts.iter().find(|(t, _)| t == label).unwrap().1;
let at = pos2(label.right() + 40.0, label.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
frame(&mut app, &ctx, vec![press(at, false)], draw);
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
let bounds = app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert_eq!((bounds.x0, bounds.y0), (100.0 + arrow.0, 50.0 + arrow.1));
assert_eq!((bounds.width(), bounds.height()), (80.0, 40.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
}
}
#[test]
fn ruler_origin_artboard_fields_preserve_the_absolute_reference_point() {
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show] {
for label in ["X:", "Y:"] {
let mut app = app();
app.run("document.setRulerOrigin", json!({"x": 120, "y": 50})).unwrap();
app.select_tool("artboard");
let ctx = context();
frame(&mut app, &ctx, vec![], draw);
let texts = frame(&mut app, &ctx, vec![], draw);
let label_rect = texts.iter().find(|(t, _)| t == label).unwrap().1;
let at = pos2(label_rect.right() + 40.0, label_rect.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
frame(&mut app, &ctx, vec![press(at, false)], draw);
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
let ab = app.session.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.x0, ab.y0), if label == "X:" { (1.0, 0.0) } else { (0.0, 1.0) });
assert_eq!((ab.width(), ab.height()), (400.0, 300.0));
}
}
}
// View › Rulers › Change to Global / Artboard Rulers (Adobe's public help:
// https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html):
// artboard rulers, a new document's, count from the active artboard's top-left corner; global
// rulers from one zero point for the whole document.
/// A document with a second artboard at (700, -250) and a rectangle on it at (720, -230).
fn two_boards() -> (VectorcraftApp, NodeId) {
let mut app = app();
app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap();
let id = NodeId(app.run("shape.rectangle", json!({"x": 720, "y": -230, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap());
(app, id)
}
#[test]
fn artboard_rulers_position_fields_count_from_the_active_artboard() {
for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show, chrome::control_bar] {
let (mut app, id) = two_boards();
let ctx = context();
frame(&mut app, &ctx, vec![], draw);
app.view_mut().unwrap().artboard = 1;
assert_eq!(app.ruler_origin(), Point::new(700.0, -250.0));
let texts = frame(&mut app, &ctx, vec![], draw);
// The reference point is the centre (760, -210): 60 and 40 from artboard 2's corner.
for v in [60.0, 40.0] {
assert!(texts.iter().any(|(t, _)| t == &Unit::Points.format(v)), "X and Y count from artboard 2: {texts:?}");
}
// Typing a position puts the object there on the active artboard's rulers.
let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1;
let at = pos2(label.right() + 40.0, label.center().y);
frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw);
frame(&mut app, &ctx, vec![press(at, false)], draw);
frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw);
let bounds = app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert_eq!((bounds.x0, bounds.y0), (721.0, -230.0));
// Global rulers: the same object reads from the document's zero point.
app.run("view.globalRulers", json!({"global": true})).unwrap();
assert_eq!(app.ruler_origin(), Point::ZERO);
frame(&mut app, &ctx, vec![], draw);
let texts = frame(&mut app, &ctx, vec![], draw);
assert!(texts.iter().any(|(t, _)| t == &Unit::Points.format(761.0)), "{texts:?}");
assert!(!texts.iter().any(|(t, _)| t == &Unit::Points.format(61.0)), "{texts:?}");
}
}
#[test]
fn ruler_origin_drag_with_artboard_rulers_sets_the_active_artboards_origin() {
let (mut app, _) = two_boards();
app.select_tool("rectangle");
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
app.view_mut().unwrap().artboard = 1;
let v = app.view_mut().unwrap();
v.center = Point::new(800.0, -175.0);
frame(&mut app, &ctx, vec![], canvas::show);
let goal = Point::new(760.0, -200.0);
let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(goal);
start_drag(&mut app, &ctx, to);
frame(&mut app, &ctx, vec![press(to, false)], canvas::show);
let doc = app.session.doc().unwrap().doc.clone();
assert!(doc.ruler_origin_for(1).distance(goal) < 1e-4, "{:?}", doc.ruler_origin_for(1));
assert_eq!(doc.ruler_origin_for(0), Point::ZERO, "the first artboard keeps its own");
assert_eq!(doc.ruler_origin, Point::ZERO, "the global zero point is apart");
assert!(app.ruler_origin().distance(goal) < 1e-4);
app.view_mut().unwrap().artboard = 0;
assert_eq!(app.ruler_origin(), Point::ZERO);
}
#[test]
fn change_to_global_rulers_menu_item_toggles_and_names_the_other_mode() {
let (mut app, _) = two_boards();
let item = crate::menus::menu_tree().iter().find(|(title, _)| *title == "View").map(|(_, items)| format!("{items:?}")).unwrap();
assert!(item.contains("view.globalRulers"), "View › Rulers lists it: {item}");
assert_eq!(crate::menus::dynamic_label(&app, "view.globalRulers", "Change to Global Rulers"), "Change to Global Rulers");
app.run("view.globalRulers", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.rulers, vectorcraft_doc::RulerMode::Global);
assert_eq!(crate::menus::dynamic_label(&app, "view.globalRulers", "Change to Global Rulers"), "Change to Artboard Rulers");
app.run("view.globalRulers", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.rulers, vectorcraft_doc::RulerMode::Artboard);
let spec = vectorcraft_engine::command_specs().iter().find(|c| c.id == "view.globalRulers").unwrap();
assert_eq!(spec.shortcut, Some("Cmd+Alt+R"));
}