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
This commit is contained in:
Victor
2026-10-17 16:38:41 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 5b1e18f2fc
commit 276eeacae6
34 changed files with 453 additions and 33 deletions
+92 -17
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
@@ -37,13 +37,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_units
),
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
cmd!(
query "document.rulerOrigin",
"Ruler Origin",
[],
None,
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
has_doc,
ruler_origin
),
cmd!(
"document.setRulerOrigin",
"Ruler Origin",
[],
None,
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
has_doc,
reset_ruler_origin
),
cmd!(
"view.globalRulers",
"Change to Global Rulers",
["View", "Rulers"],
Some("Cmd+Alt+R"),
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
has_doc,
global_rulers
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -229,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
};
if index >= s.doc()?.doc.artboards.len() {
return Err(bad(c, "index out of range"));
}
Ok(index)
}
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// A document without artboards measures from its global zero point.
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
change_ruler_origin(s, origin, "Ruler Origin")
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
}
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.resetRulerOrigin";
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
};
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
change_ruler_origin(s, index, None, "Reset Ruler Origin")
}
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
/// corner so it follows the artboard).
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let global = d.rulers == RulerMode::Global;
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
let target = origin.or(corner).unwrap_or(Point::ZERO);
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD {
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
}
if s.doc()?.doc.ruler_origin != origin {
if global {
if d.ruler_origin != target {
s.edit(label, |d, _| {
d.ruler_origin = target;
Ok(())
})?;
}
return Ok(json!(target));
}
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
return Err(bad("document.setRulerOrigin", "index out of range"));
};
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
if d.artboard_ruler_origins.get(&id).copied() != offset {
s.edit(label, |d, _| {
d.ruler_origin = origin;
// Origins of artboards that no longer exist go too.
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
match offset {
Some(o) => d.artboard_ruler_origins.insert(id, o),
None => d.artboard_ruler_origins.remove(&id),
};
Ok(())
})?;
}
Ok(json!(origin))
Ok(json!(target))
}
/// View › Rulers › Change to Global / Artboard Rulers.
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
let current = s.doc()?.doc.rulers == RulerMode::Global;
let global = match p.get("global") {
None => !current,
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
};
if global != current {
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
s.edit(label, |d, _| {
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
Ok(())
})?;
}
Ok(json!({ "global": global }))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).