Merge pull request #2739 from guillehr2/fix/1876-artboard-options-xy

Fix #1876: Artboard Options has X and Y with a reference point
This commit is contained in:
BFlat
2026-10-18 01:48:09 +07:00
committed by GitHub
6 changed files with 203 additions and 9 deletions
+67 -3
View File
@@ -1,13 +1,77 @@
//! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards
//! panel's options icon or menu): the artboard's fields, sizes parsed with units.
//! panel's options icon or menu): the artboard's fields, sizes parsed with units, and its position
//! from a reference point.
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_geom::{Point, Rect};
use super::{DialogSpec, run_and_close};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::transform::{artboard_rect, reference_index, reference_locator};
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), body, confirm, ..DialogSpec::FORM };
/// Where the fields put the artboard: `x` and `y` are its top-left corner in document points, as
/// `artboard.setProps` and `artboard.new` take them.
fn placed(d: &Dialog) -> Rect {
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
Rect::new(x, y, x + d.f64("width", 0.0), y + d.f64("height", 0.0))
}
/// The form's Name, Width and Height, then X and Y in the same grid, the reference point locator
/// before X (#1876). The locator is the one the Transform panel, the Control bar and Properties
/// share, and X and Y are where its point lies on the rulers, counted from the ruler origin (#1212),
/// as the Transform panel and the Control bar show an artboard's. A new Width or Height keeps the
/// reference point where it was, as their sizes do.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui));
let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0));
form::grid_then(ui, d, unit, |ui, d| {
let (w, h) = (d.f64("width", 0.0), d.f64("height", 0.0));
let n = artboard_rect(was, refi, None, None, Some(w), Some(h), false);
// Only the side that changed moves, so an untouched coordinate stays exactly as it was.
for (key, changed, at) in [("x", w != w0, n.x0), ("y", h != h0, n.y0)] {
if changed {
d.fields.insert(key.into(), json!(at));
}
}
position_rows(ui, d, refi, unit, origin);
});
false
}
/// The X and Y rows: where reference point `refi` lies, counted from the ruler origin `origin`,
/// with the locator before X's label. A typed X or Y moves the artboard so that its reference point
/// lands there.
fn position_rows(ui: &mut egui::Ui, d: &mut Dialog, refi: usize, unit: Unit, origin: Point) {
let r = placed(d);
let rp = vectorcraft_geom::reference_point(r, refi);
let mut indent = 0.0;
for (key, label, at, zero) in [("x", "X:", rp.x, origin.x), ("y", "Y:", rp.y, origin.y)] {
ui.horizontal(|ui| {
// Y's label lines up with X's, after the locator.
if key == "x" {
let left = ui.cursor().min.x;
reference_locator(ui, refi);
indent = ui.min_rect().right() - left;
} else {
ui.add_space(indent);
}
form::row_label(ui, label);
});
if let Some(v) = crate::widgets::num_field(ui, ("dlg-ab-xy", key), Some(at - zero), unit, form::FIELD_W) {
let n = if key == "x" {
artboard_rect(r, refi, Some(v + zero), None, None, None, false).x0
} else {
artboard_rect(r, refi, None, Some(v + zero), None, None, false).y0
};
d.fields.insert(key.into(), json!(n));
}
ui.end_row();
}
}
/// The dialog kind of Artboard Options.
pub const KIND: &str = "artboardOptions";
+8 -1
View File
@@ -8,7 +8,7 @@ use crate::state::Dialog;
use crate::theme::Tokens;
/// Width of a dialog's value fields ([`text`] and [`length`], frame included).
const FIELD_W: f32 = 132.0;
pub(super) const FIELD_W: f32 = 132.0;
/// A labelled text field bound to `d.fields[key]` (one grid row).
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
@@ -193,6 +193,12 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the
/// [`skew_ends`] fields as sliders.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
grid_then(ui, d, unit, |_, _| {});
}
/// [`grid`], then the rows `more` draws in the same grid, so that their labels and fields line up
/// with the generic ones.
pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: impl FnOnce(&mut egui::Ui, &mut Dialog)) {
let rail = Tokens::get(ui.ctx()).input_border;
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let mut keys: Vec<String> = d
@@ -237,6 +243,7 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
ui.end_row();
}
}
more(ui, d);
});
}
+119 -2
View File
@@ -1,6 +1,7 @@
//! The dialog registry and the shared frame, drawn headlessly.
use serde_json::json;
use vectorcraft_doc::Unit;
use vectorcraft_engine::Session;
use super::*;
@@ -1018,6 +1019,111 @@ fn artboard_options_position_follows_the_tool_option_with_another_tool() {
}
}
/// The strings a frame painted, each with its rectangle.
fn painted(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> {
fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
}
/// Where to click in the field after the label `label` among the `shown` strings.
fn field_after(shown: &[(String, egui::Rect)], label: &str) -> egui::Pos2 {
let r = shown.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1;
egui::pos2(r.right() + 40.0, r.center().y)
}
/// A 400 × 300 pt artboard at the page's origin with the ruler origin at (120, 50), and its
/// Artboard Options open.
fn artboard_options_from_a_ruler_origin() -> VectorcraftApp {
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": 120, "y": 50})).unwrap();
assert_eq!(app.ruler_origin(), vectorcraft_geom::Point::new(120.0, 50.0));
app.run("tool.options", json!({"tool": "artboard"})).unwrap();
app
}
/// #1876: Artboard Options has X and Y beside the reference point locator, counted from the ruler
/// origin (#1212) as the Transform panel's are. The artboard moves to a typed X and Y, its reference
/// point landing there, in one undo step. The shape dialogs still show no X and Y (theirs are where
/// the click was).
#[test]
fn artboard_options_x_and_y_count_from_the_ruler_origin_at_the_reference_point() {
let mut app = artboard_options_from_a_ruler_origin();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let mut f = Frames::new();
let has = |shown: &[(String, egui::Rect)], s: &str| shown.iter().any(|(t, _)| t == s);
// The center by default: (200, 150) on the page, 80 and 100 from the ruler origin.
let shown = f.texts(&mut app);
for s in ["X:".to_string(), "Y:".to_string(), Unit::Points.format(80.0), Unit::Points.format(100.0)] {
assert!(has(&shown, &s), "{s} in {shown:?}");
}
// The bottom-right corner: (400, 300) on the page, 280 and 250 from the ruler origin.
let corner = f.control(&mut app, "Bottom Right", egui::accesskit::Role::RadioButton);
f.press(&mut app, corner, true);
f.press(&mut app, corner, false);
let shown = f.texts(&mut app);
for v in [280.0, 250.0] {
assert!(has(&shown, &Unit::Points.format(v)), "{v} in {shown:?}");
}
// Typing X 300 and Y 0 puts that corner at (420, 50) on the page.
for (label, typed) in [("X:", "300"), ("Y:", "0")] {
let at = field_after(&shown, label);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
f.run(&mut app, vec![egui::Event::Text(typed.into())]);
}
f.key(&mut app, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_none(), "Enter applies the dialog");
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(20.0, -250.0, 420.0, 50.0));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
open(&mut app, "rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40}));
let shown = f.texts(&mut app);
assert!(has(&shown, "Width:") && !has(&shown, "X:") && !has(&shown, "Y:"), "{shown:?}");
}
/// #1876: a new Width or Height in Artboard Options keeps the reference point where it was, as the
/// Transform panel's does: about the center by default, so X and Y still read the same, and from
/// the top-left corner with that reference point.
#[test]
fn artboard_options_size_keeps_the_reference_point() {
for (point, want) in
[(None, vectorcraft_geom::Rect::new(-50.0, 0.0, 450.0, 300.0)), (Some("Top Left"), vectorcraft_geom::Rect::new(0.0, 0.0, 500.0, 300.0))]
{
let mut app = artboard_options_from_a_ruler_origin();
let mut f = Frames::new();
if let Some(point) = point {
let at = f.control(&mut app, point, egui::accesskit::Role::RadioButton);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
}
let shown = f.texts(&mut app);
let (width, height) = (field_after(&shown, "Width:"), field_after(&shown, "Height:"));
f.press(&mut app, width, true);
f.press(&mut app, width, false);
f.run(&mut app, vec![egui::Event::Text("500".into())]);
// A click on Height ends the Width field's editing; X reads as before.
f.press(&mut app, height, true);
f.press(&mut app, height, false);
let shown = f.texts(&mut app);
let x = Unit::Points.format(if point.is_some() { -120.0 } else { 80.0 });
assert!(shown.iter().any(|(t, _)| *t == x), "{x} in {shown:?}");
f.key(&mut app, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_none(), "Enter applies the dialog");
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, want, "{point:?}");
}
}
/// Zig Zag's dialog has Size and Ridges per segment sliders, and Relative / Absolute and Smooth /
/// Corner as radio buttons, not dropdowns (#1133).
#[test]
@@ -1058,13 +1164,24 @@ impl Frames {
out
}
/// The strings the dialog layer paints once it has settled, each with its rectangle.
fn texts(&mut self, app: &mut VectorcraftApp) -> Vec<(String, egui::Rect)> {
self.run(app, vec![]);
painted(&self.run(app, vec![]))
}
/// The center of the button that screen readers name `label`.
fn button(&mut self, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
self.control(app, label, egui::accesskit::Role::Button)
}
/// The center of the control of `role` that screen readers name `label`.
fn control(&mut self, app: &mut VectorcraftApp, label: &str, role: egui::accesskit::Role) -> egui::Pos2 {
self.run(app, vec![]);
let out = self.run(app, vec![]);
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button);
let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds");
let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == role);
let b = node.unwrap_or_else(|| panic!("no {role:?} named {label:?}")).1.bounds().expect("bounds");
egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)
}
+3 -1
View File
@@ -3858,7 +3858,9 @@ desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`)
`direction` (°), and `raysOn` and `ringsOn` (the Rays and Rings checkboxes; off, the flare has none). OK keeps them
(`tool.setOption {tool: "flare", values}`) and the next flare dragged out uses them; a click with the tool opens the
same dialog, and its OK also draws a flare there. The Artboard tool's are the active artboard's Artboard Options
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's), a graph tool's
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's; its `x` and `y`
are the artboard's top-left corner in document points, while the dialog shows X and Y at the reference point the
Transform panel uses, counted from the ruler origin), a graph tool's
the selected graph's Graph Type (an error without one), a Symbolism tool's the Symbolism Tools Options (dialog
`symbolismOptions`: `diameter` in points, `intensity` and `density` 1–10, one brush for the eight tools) and the
Magic Wand tool's its panel. `ui.newArtboard` (Alt-click on the Artboards panel's New Artboard) opens Artboard Options
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long