Files
vectorcraft/crates/ui-egui/src/panels/properties/tests.rs
T
nevaremandClaude Opus 5.5 a7db76bdfb Properties: selected ruler guides show their position in a Transform section
With ruler guides selected on their own (a click on one), the
Properties panel showed the Document sections, so a guide's position
could be typed only in the Transform panel.

The panel now has a Guides heading and a Transform section with the
Transform panel's guide fields (#1597): X moves the vertical guides and
Y the horizontal ones, together. With art selected too, it shows the
art's sections as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjfPR4a1jPek7r3nyFFNYv
2026-10-29 18:46:47 +07:00

1650 lines
89 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 egui::{Event, PointerButton, Pos2, Rect, vec2};
use serde_json::Value;
use vectorcraft_engine::Session;
use super::*;
/// One frame of the panel with `events` → the texts painted, with their rects.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
panel_frame(app, ctx, events, show)
}
/// One frame of `draw` with `events` → the texts painted, with their rects.
fn panel_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(300.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| {
if let egui::Shape::Text(t) = &s.shape { Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) } else { None }
})
.collect()
}
/// Clicks the text `label` where the last frame painted it, over two frames (press, release).
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) {
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"));
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![Event::PointerMoved(at), b(true)]);
frame(app, ctx, vec![b(false)]);
}
/// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel,
/// under its Clip Group heading and before every section, and they select its clipping path or
/// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip
/// group selected with another object, has neither.
#[test]
fn one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let art = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 140})).unwrap()["id"].clone();
let mask = app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [art, mask]})).unwrap();
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [group]})).unwrap();
let ctx = egui::Context::default();
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>();
let labels = |texts: &[(String, Rect)]| texts.iter().map(|(t, _)| t.clone()).collect::<Vec<_>>();
let texts = frame(&mut app, &ctx, vec![]);
let shown = labels(&texts);
let at = |label: &str| shown.iter().position(|t| t == label).unwrap_or_else(|| panic!("{label} in {shown:?}"));
assert_eq!(at("Clip Group"), 0, "{shown:?}");
assert!(at("Edit Clipping Path") == 1 && at("Edit Contents") == 2, "{shown:?}");
click(&mut app, &ctx, &texts, "Edit Contents");
assert_eq!(selected(&app), std::slice::from_ref(&art), "Edit Contents selects the clipped art");
app.run("select.set", json!({"ids": [group]})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Edit Clipping Path");
assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path");
let neither = |app: &mut VectorcraftApp| {
let shown = labels(&frame(app, &ctx, vec![]));
!shown.iter().any(|t| t == "Edit Clipping Path" || t == "Edit Contents")
};
// The clip group with another object, whichever is selected first.
let other = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [group, other]})).unwrap();
assert!(neither(&mut app), "a clip group and another object");
app.run("select.set", json!({"ids": [other, group]})).unwrap();
assert!(neither(&mut app), "another object and a clip group");
// A plain group.
let b = app.run("shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [other, b]})).unwrap();
let plain = app.run("object.group", json!({})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [plain]})).unwrap();
assert!(labels(&frame(&mut app, &ctx, vec![])).iter().any(|t| t == "Ungroup"));
assert!(neither(&mut app), "a plain group");
// A clip group whose clipped art is deleted: Edit Clipping Path alone.
app.run("select.set", json!({"ids": [art]})).unwrap();
app.run("edit.clear", json!({})).unwrap();
app.run("select.set", json!({"ids": [group]})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
let shown = labels(&texts);
assert_eq!(shown.iter().position(|t| t == "Edit Clipping Path"), Some(1), "{shown:?}");
assert!(!shown.iter().any(|t| t == "Edit Contents"), "nothing to edit: {shown:?}");
click(&mut app, &ctx, &texts, "Edit Clipping Path");
assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path");
}
/// Start Global Edit in Quick Actions starts it on the one selected path, the button and the
/// Select menu item then read Stop Global Edit, and Escape (the app's key handling) stops it.
/// Selected type has no Start Global Edit button.
#[test]
fn start_global_edit_quick_action_and_escape() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let ctx = egui::Context::default();
// Tall enough for Quick Actions at the foot of the panel.
let tall = |app: &mut VectorcraftApp, events: Vec<Event>| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
_ => None,
})
.collect::<Vec<_>>()
};
let on = |app: &VectorcraftApp| app.session.active().unwrap().global_edit.is_some();
let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "select.globalEdit", "Start Global Edit");
assert_eq!(label(&app), "Start Global Edit");
let texts = tall(&mut app, vec![]);
let at = texts.iter().find(|(t, _)| t == "Start Global Edit").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}"));
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
tall(&mut app, vec![Event::PointerMoved(at), b(true)]);
tall(&mut app, vec![b(false)]);
assert!(on(&app), "the Quick Action starts Global Edit");
assert_eq!(app.session.active().unwrap().global_edit.as_ref().unwrap().members.len(), 2);
assert!(tall(&mut app, vec![]).iter().any(|(t, _)| t == "Stop Global Edit"));
assert_eq!(label(&app), "Stop Global Edit");
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx()));
out.textures_delta.clear();
assert!(!on(&app), "Escape stops Global Edit");
assert_eq!(label(&app), "Start Global Edit");
assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>(), [a]);
let t = app.run("text.create", json!({"x": 50, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [t]})).unwrap();
assert!(!crate::menus::enabled(&app, "select.globalEdit"));
assert!(!tall(&mut app, vec![]).iter().any(|(t, _)| t == "Start Global Edit"), "type has no Global Edit");
}
/// The options' Result(s) count is found once per document revision, options and selected
/// object, not every frame; another selected object or other options find it again.
#[test]
fn the_global_edit_result_count_is_cached() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap();
let e = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a]})).unwrap();
let ctx = egui::Context::default();
assert_eq!(global_edit_count(&app, &ctx), 2);
// The next frame reads the cache: a count planted under the same key is what it shows.
let (key, _) = pstate::<GlobalEditCount>(&ctx, "props-global-edit-count").unwrap();
set_pstate::<GlobalEditCount>(&ctx, "props-global-edit-count", Some((key, 7)));
assert_eq!(global_edit_count(&app, &ctx), 7);
app.run("select.set", json!({"ids": [e]})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 1, "another selected object");
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 2);
app.run("select.globalEditOptions", json!({"size": true})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 1, "other options");
}
// Regression test for #1910: the Properties panel's flip buttons are named as the reference
// app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform
// panel menu's names).
#[test]
fn the_flip_buttons_keep_the_reference_properties_names() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
let tree = crate::widgets::accessibility_tests::tree(|ui| transform_section(&mut app, ui));
let names: Vec<&str> = tree.iter().filter_map(|n| n.label()).collect();
for want in ["Flip Along Horizontal Axis", "Flip Along Vertical Axis"] {
assert!(names.contains(&want), "{want} in {names:?}");
}
for old in ["Flip Horizontal", "Flip Vertical"] {
assert!(!names.contains(&old), "{old} in {names:?}");
}
}
/// #3451: the Properties panel Transform section shows ellipse pie angles and Invert Pie.
#[test]
fn transform_section_shows_ellipse_pie_properties() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap();
let ctx = egui::Context::default();
let labels: Vec<String> = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect();
for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] {
assert!(labels.iter().any(|s| s == t), "{t} in {labels:?}");
}
app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap();
let labels: Vec<String> = panel_frame(&mut app, &ctx, vec![], transform_section).into_iter().map(|(t, _)| t).collect();
assert!(labels.iter().any(|s| s == "30°") && labels.iter().any(|s| s == "120°"), "{labels:?}");
}
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
#[test]
fn the_document_checkboxes_show_their_boxes_and_toggle() {
for brightness in crate::theme::Brightness::ALL {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, brightness);
frame(&mut app, &ctx, vec![]);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
out.textures_delta.clear();
let boxes = out
.shapes
.iter()
.filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5))
.count();
assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines");
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame(&mut app, &ctx, vec![]);
let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Snap to Grid");
assert_eq!(app.ui.view.snap_to_grid, !grid);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !strokes);
}
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
/// Draw as Points or Lines radio buttons.
#[test]
fn the_gradient_tool_shows_the_gradient_type_and_draw() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"gradient": {}})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Gradient"), "only with the Gradient tool");
app.select_tool("gradient");
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Gradient") && has(&texts, "Type:") && !has(&texts, "Draw:"), "{texts:?}");
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Draw:") && has(&texts, "Points") && has(&texts, "Lines"), "{texts:?}");
click(&mut app, &ctx, &texts, "Lines");
let fill = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let vectorcraft_color::Paint::Gradient(g) = fill else { panic!("{fill:?}") };
assert_eq!(super::super::gradient::shown_points(&g).mode, vectorcraft_color::FreeformMode::Lines);
}
/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner
/// radius, Opacity and the stroke weight.
#[test]
fn the_number_fields_are_all_as_wide() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
// The field each value is shown in: the narrowest widget around its text that isn't the
// text alone.
let field = |value: &str| {
let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}"));
ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0)
.map(|w| w.rect.width())
.fold(f32::INFINITY, f32::min)
})
};
let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")];
assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}");
}
/// #1512: the link behind Corner Radius. On, one field sets every corner; off, a field per
/// corner shows each corner's own radius.
#[test]
fn the_corner_radius_link_shows_a_field_per_corner() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("object.setLiveShape", json!({"radius": 7, "corners": [2]})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(app.session.prefs.single_corner_radius, "linked at first");
assert!(has(&texts, "Corner Radius:") && !has(&texts, "Top Left:") && !has(&texts, "7 pt"), "{texts:?}");
app.run("prefs.set", json!({"key": "singleCornerRadius", "value": false})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
for l in ["Corner Radius:", "Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"] {
assert!(has(&texts, l), "{l}: {texts:?}");
}
assert_eq!(texts.iter().filter(|(t, _)| t == "7 pt").count(), 1, "the bottom-right corner: {texts:?}");
// The fields sit in line, in the order top left, top right, bottom right, bottom left.
let at = |s: &str| texts.iter().find(|(t, _)| t == s).unwrap().1;
assert!(at("Top Left:").top() < at("Top Right:").top() && at("Top Right:").top() < at("Bottom Right:").top());
assert!((at("7 pt").top() - at("Bottom Right:").top()).abs() < 6.0, "{texts:?}");
}
/// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and
/// stays open while they are toggled.
#[test]
fn transform_more_options_toggles_scale_corners_and_strokes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Scale Corners"), "closed at first");
// The button is an icon at the end of the angle row, under the H: field: sweep that row.
let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1;
let mut open = None;
'sweep: for dy in (12..60).step_by(6) {
for x in (120..300).step_by(6) {
let at = egui::pos2(x as f32, h.bottom() + dy as f32);
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]);
frame(&mut app, &ctx, vec![b(false)]);
let texts = frame(&mut app, &ctx, vec![]);
if has(&texts, "Scale Corners") {
open = Some(texts);
break 'sweep;
}
}
}
let texts = open.expect("More Options opens the popup");
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Scale Corners");
assert_eq!(app.session.prefs.scale_corners, !sc);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle");
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !ss);
}
/// Both panels forward the Move Artwork toggle when their actual X field is edited.
#[test]
fn artboard_coordinate_fields_move_art_in_properties_and_transform() {
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), super::super::transform::show] {
for move_art in [false, true] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.select_tool("artboard");
app.session.set_tool_option("moveArt", &json!(move_art));
let ctx = egui::Context::default();
panel_frame(&mut app, &ctx, vec![], draw);
let texts = panel_frame(&mut app, &ctx, vec![], draw);
let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1;
let at = egui::pos2(label.right() + 40.0, label.center().y);
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw);
panel_frame(&mut app, &ctx, vec![press(false)], draw);
panel_frame(
&mut app,
&ctx,
vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
draw,
);
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect.x0, 1.0, "the focused X field steps its position");
assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, if move_art { 11.0 } else { 10.0 });
app.run("edit.undo", json!({})).unwrap();
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect.x0, 0.0);
assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, 10.0);
}
}
}
/// Three landscape artboards, with Artboards 1 and 3 selected with the Artboard tool (Artboard
/// 1 active): 200 × 100, 200 × 100 and 300 × 150.
fn two_selected_artboards() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
app.run("artboard.setProps", json!({"index": 2, "width": 300, "height": 150})).unwrap();
app.select_tool("artboard");
super::super::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
app
}
/// Each artboard's (x0, y0, width, height).
fn boards(app: &VectorcraftApp) -> Vec<(f64, f64, f64, f64)> {
app.session.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect()
}
/// #1482: with Artboards 1 and 3 selected, a W typed into Properties or the Transform panel
/// resizes both in one undo step, and Artboard 2 keeps its size. Properties keeps each top-left
/// corner; the Transform panel resizes each about its own center, its default reference point.
#[test]
fn artboard_width_resizes_every_selected_artboard_in_properties_and_transform() {
for (draw, centered) in [(show as fn(&mut VectorcraftApp, &mut Ui), false), (super::super::transform::show, true)] {
let mut app = two_selected_artboards();
let before = boards(&app);
let undo = app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
panel_frame(&mut app, &ctx, vec![], draw);
let texts = panel_frame(&mut app, &ctx, vec![], draw);
let label = texts.iter().find(|(t, _)| t == "W:").unwrap().1;
let at = egui::pos2(label.right() + 40.0, label.center().y);
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw);
panel_frame(&mut app, &ctx, vec![press(false)], draw);
panel_frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
panel_frame(&mut app, &ctx, vec![Event::Text("250".into())], draw);
panel_frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
panel_frame(&mut app, &ctx, vec![], draw);
let mut want = before.clone();
for (i, shift) in [(0, 25.0), (2, -25.0)] {
want[i].2 = 250.0;
if centered {
want[i].0 -= shift;
}
}
assert_eq!(boards(&app), want, "centered: {centered}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(boards(&app), before);
}
}
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
/// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on.
#[test]
fn type_paragraph_section_hyphenates() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Hyphenate");
let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate;
assert!(hyphenate(&app));
}
/// Variable fonts get the same Variable Font button in Properties as in the Character panel.
#[test]
fn properties_variable_font_button_for_variable_type() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let plain = app.run("text.create", json!({"x": 20, "y": 50, "text": "Static"})).unwrap()["id"].clone();
let id = app.run("text.create", json!({"x": 20, "y": 120, "text": "lIn", "font": TWO_AXIS_FAMILY})).unwrap()["id"].clone();
let ctx = egui::Context::default();
app.run("select.set", json!({"ids": [plain]})).unwrap();
assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font"));
app.run("select.set", json!({"ids": [id]})).unwrap();
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Variable Font"));
}
#[test]
fn edit_artboards_adds_artboards_and_exits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Edit Artboards");
assert_eq!(app.session.tool_id(), "artboard");
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}");
click(&mut app, &ctx, &texts, "New Artboard");
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len();
assert_eq!(boards(&app), 2);
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}");
click(&mut app, &ctx, &texts, "Delete Artboard");
assert_eq!(boards(&app), 1);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Exit");
assert_eq!(app.session.tool_id(), "selection");
}
/// Typing a name into Edit Artboards' name field and pressing Enter renames the active artboard
/// alone, trimmed as the Artboards panel's rename trims it, with one command and one undo step;
/// a click elsewhere in the panel renames it too. Pressing Escape or leaving the field empty
/// keeps the name and runs no command.
#[test]
fn edit_artboards_renames_the_active_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
app.select_tool("artboard");
super::super::artboards::select(&mut app, 1);
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.artboards.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
let key = |key, modifiers| vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }];
// Click the field showing Artboard 2's name, select all of it, type `typed`, then send each
// frame's events of `end` → the number of commands run.
let rename = |app: &mut VectorcraftApp, typed: Vec<Event>, end: Vec<Vec<Event>>| {
frame(app, &ctx, vec![]);
let texts = frame(app, &ctx, vec![]);
let name = app.session.doc().unwrap().doc.artboards[1].name.clone();
click(app, &ctx, &texts, &name);
frame(app, &ctx, key(egui::Key::A, egui::Modifiers::COMMAND));
let commands = app.run_count;
frame(app, &ctx, typed);
for events in end {
frame(app, &ctx, events);
}
frame(app, &ctx, vec![]);
app.run_count.wrapping_sub(commands)
};
let enter = || vec![key(egui::Key::Enter, egui::Modifiers::NONE)];
let text = |s: &str| vec![Event::Text(s.into())];
assert_eq!(rename(&mut app, text("Cover"), vec![key(egui::Key::Escape, egui::Modifiers::NONE)]), 0, "Escape");
assert_eq!(rename(&mut app, key(egui::Key::Backspace, egui::Modifiers::NONE), enter()), 0, "an empty field");
assert_eq!(app.session.doc().unwrap().doc.artboards, before);
assert_eq!(rename(&mut app, text(" Cover "), enter()), 1);
let mut renamed = before.clone();
renamed[1].name = "Cover".into();
let st = app.session.doc().unwrap();
assert_eq!(st.doc.artboards, renamed, "Artboard 2 alone is renamed");
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.doc.artboards, before);
assert!(!st.is_dirty());
// The click elsewhere is a press and release on the panel's "Artboard" heading, outside
// the field.
let at = frame(&mut app, &ctx, vec![]).iter().find(|(t, _)| t == "Artboard").map(|(_, r)| r.center()).unwrap();
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
assert_eq!(rename(&mut app, text("Back"), vec![vec![Event::PointerMoved(at), b(true)], vec![b(false)]]), 1, "a click elsewhere");
renamed[1].name = "Back".into();
assert_eq!(app.session.doc().unwrap().doc.artboards, renamed);
}
/// A click on Edit Artboards' Artboard Options icon opens Artboard Options for the active
/// artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`).
#[test]
fn edit_artboards_opens_the_active_artboards_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
app.select_tool("artboard");
super::super::artboards::select(&mut app, 1);
app.run("tool.options", json!({"tool": "artboard"})).unwrap();
let want = app.ui.dialog.take().unwrap().fields;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
// The icon is the rightmost clickable widget on the name's line.
let name = texts.iter().find(|(t, _)| t == "Artboard 2").map(|(_, r)| *r).unwrap();
let icon = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(name.center().y))
.map(|w| w.rect)
.max_by(|a, b| a.left().total_cmp(&b.left()))
.unwrap()
.center()
});
let commands = app.run_count;
let press = |pressed| Event::PointerButton { pos: icon, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(icon), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
assert_eq!(app.run_count, commands.wrapping_add(1), "one command");
let d = app.ui.dialog.take().expect("Artboard Options");
assert_eq!((d.kind.as_str(), d.f64("index", 9.0), d.str("name").as_str()), (crate::dialogs::artboard_options::KIND, 1.0, "Artboard 2"));
assert_eq!(d.fields, want, "the fields the tool's double-click shows");
assert_eq!(app.session.tool_id(), "artboard");
}
/// Edit Artboards' Fill (Properties › Artboards › Fill): the list sets the active artboard's
/// background color to Black and back to None, one command and one undo step each, and shows the
/// choice. Color Picker… in the list and a click on the chip open the Color Picker for that
/// artboard.
#[test]
fn edit_artboards_fill_sets_the_background_color() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
app.select_tool("artboard");
super::super::artboards::select(&mut app, 1);
let colors = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.iter().map(|a| a.color).collect::<Vec<_>>();
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
// Open the list showing `shown` and choose `option` → the number of commands run.
let choose = |app: &mut VectorcraftApp, shown: &str, option: &str| {
frame(app, &ctx, vec![]);
let texts = frame(app, &ctx, vec![]);
click(app, &ctx, &texts, shown);
let texts = frame(app, &ctx, vec![]);
let commands = app.run_count;
click(app, &ctx, &texts, option);
app.run_count.wrapping_sub(commands)
};
let before = undo(&app);
assert_eq!(choose(&mut app, "None", "Black"), 1, "one command");
assert_eq!(colors(&app), [None, Some(Color::BLACK)]);
assert_eq!(undo(&app), before + 1, "one undo step");
assert_eq!(choose(&mut app, "Black", "None"), 1);
assert_eq!(colors(&app), [None, None]);
choose(&mut app, "None", "Color Picker…");
let d = app.ui.dialog.take().expect("the Color Picker");
assert_eq!((d.kind.as_str(), d.fields.get("artboard")), ("colorPicker", Some(&json!(1))));
// The chip is the clickable widget left of the list on the Fill: row.
let texts = frame(&mut app, &ctx, vec![]);
let row = texts.iter().find(|(t, _)| t == "Fill:").map(|(_, r)| *r).unwrap();
let chip = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.min_by(|a, b| a.left().total_cmp(&b.left()))
.unwrap()
.center()
});
let press = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(chip), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields.get("artboard")), Some(Some(&json!(1))), "the chip opens it too");
}
fn artboard_deletion_app(count: usize) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": count})).unwrap();
for i in 0..count {
app.run("shape.rectangle", json!({"x": i * 250 + 10, "y": 10, "width": 30, "height": 20})).unwrap();
}
app.select_tool("artboard");
super::super::artboards::select(&mut app, 0);
app
}
fn artboard_deletion_click(
app: &mut VectorcraftApp,
ctx: &egui::Context,
draw: fn(&mut VectorcraftApp, &mut Ui),
label: Option<&str>,
modifiers: egui::Modifiers,
) {
let at = if let Some(label) = label {
panel_frame(app, ctx, vec![], draw);
let texts = panel_frame(app, ctx, vec![], draw);
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
} else {
// The trash is the last 24-point footer button. Use the drawn panel bounds, as the Asset Export bin test uses its footer.
let mut bounds = Rect::NOTHING;
for _ in 0..2 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
draw(app, ui);
bounds = ui.min_rect();
});
out.textures_delta.clear();
}
assert!(bounds.width() >= 24.0 && bounds.height() >= 24.0);
bounds.right_bottom() - vec2(12.0, 12.0)
};
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers };
panel_frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(at), button(true)], draw);
panel_frame(app, ctx, vec![button(false)], draw);
panel_frame(app, ctx, vec![Event::ModifiersChanged(egui::Modifiers::NONE)], draw);
}
fn select_artboard_rows(app: &mut VectorcraftApp, ctx: &egui::Context, indices: &[usize]) {
for (at, i) in indices.iter().enumerate() {
let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND };
artboard_deletion_click(app, ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers);
}
let count = app.session.doc().unwrap().doc.artboards.len();
assert_eq!(super::super::artboards::picked(app, count), indices, "the rows are selected before deletion");
}
fn assert_artboard_deletion(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) {
for indices in [vec![0, 2], vec![2], vec![3], vec![2, 3], vec![0, 1, 2, 3]] {
let mut app = artboard_deletion_app(4);
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &indices);
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let all_selected = indices == [0, 1, 2, 3];
let expected: Vec<_> = if all_selected {
assert_eq!(app.view().unwrap().artboard, 3, "the active last board must not replace the first-board survivor oracle");
vec![&before.artboards[0]]
} else {
before.artboards.iter().enumerate().filter(|(i, _)| !indices.contains(i)).map(|(_, a)| a).collect()
};
let expected = serde_json::to_value(expected).unwrap();
let artwork = serde_json::to_value(&before.layers).unwrap();
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE);
let st = app.session.doc().unwrap();
assert_eq!(
serde_json::to_value(&st.doc.artboards).unwrap(),
expected,
"selected deletion keeps the expected IDs; all-selected keeps original board 1"
);
assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual deletion control");
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork, "artwork IDs, geometry and appearance stay intact");
assert_eq!(st.history.undo.len(), undo + 1, "one deletion is one undo step");
assert!(st.is_dirty());
let active = app.view().unwrap().artboard;
if all_selected {
assert_eq!(active, 0);
}
assert!(active < st.doc.artboards.len(), "the active artboard remains in range");
assert_eq!(app.session.tool_options()["active"], json!(active));
assert_eq!(super::super::artboards::picked(&app, st.doc.artboards.len()), [active]);
assert_eq!(app.session.tool_options()["selected"], json!([active]), "raw tool selection contains only the remaining active board");
app.run("edit.undo", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap());
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork);
assert_eq!(st.history.undo.len(), undo);
assert!(!st.is_dirty(), "one undo returns to the saved document");
app.run("edit.redo", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), expected, "redo removes the same artboard IDs");
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork);
assert_eq!(st.history.undo.len(), undo + 1);
assert!(st.is_dirty());
}
}
fn assert_artboard_deletion_guard(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) {
let mut app = artboard_deletion_app(1);
// Leave a redo entry to ensure a disabled action preserves both history branches.
app.run("artboard.setProps", json!({"index": 0, "name": "Temporary"})).unwrap();
app.run("edit.undo", json!({})).unwrap();
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &[0]);
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let st = app.session.doc().unwrap();
let history = (st.history.undo.len(), st.history.redo.len());
assert!(history.1 > 0);
let active = app.view().unwrap().artboard;
let options = app.session.tool_options();
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE);
assert_eq!(app.run_count, commands, "the disabled last-board control dispatches no command");
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap());
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), serde_json::to_value(&before.layers).unwrap());
assert_eq!((st.history.undo.len(), st.history.redo.len()), history);
assert!(!st.is_dirty(), "deleting the last artboard is a no-op");
assert_eq!(app.view().unwrap().artboard, active);
assert_eq!(app.session.tool_options(), options);
}
#[test]
fn artboard_menu_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(super::super::artboards::menu, Some("Delete Artboards"));
}
#[test]
fn properties_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(show, Some("Delete Artboard"));
}
#[test]
fn artboard_menu_preserves_last_artboard() {
assert_artboard_deletion_guard(super::super::artboards::menu, Some("Delete Artboards"));
}
#[test]
fn properties_preserves_last_artboard() {
assert_artboard_deletion_guard(show, Some("Delete Artboard"));
}
#[test]
fn artboard_trash_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(super::super::artboards::show, None);
}
#[test]
fn artboard_trash_preserves_last_artboard() {
assert_artboard_deletion_guard(super::super::artboards::show, None);
}
/// Click the actual panel footer's New button (24 points, with a 4-point gap before Trash).
fn artboard_history_new(app: &mut VectorcraftApp, ctx: &egui::Context) {
let mut bounds = Rect::NOTHING;
for _ in 0..2 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
super::super::artboards::show(app, ui);
bounds = ui.min_rect();
});
out.textures_delta.clear();
}
let count = app.session.doc().unwrap().doc.artboards.len();
let commands = app.run_count;
let at = bounds.right_bottom() - vec2(40.0, 12.0);
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
panel_frame(app, ctx, vec![Event::PointerMoved(at), button(true)], super::super::artboards::show);
panel_frame(app, ctx, vec![button(false)], super::super::artboards::show);
assert_eq!(app.run_count, commands.wrapping_add(1), "the actual New button dispatches once");
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), count + 1);
assert_eq!(app.session.tool_options()["active"], json!(count));
assert_eq!(app.view().unwrap().artboard, count);
}
/// Keep the panel and its natural focus in the same context throughout Undo and deletion.
fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
for pressed in [true, false] {
let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }];
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::artboards::show(app, ui);
});
out.textures_delta.clear();
}
}
/// Duplicate the artboards, or add a new one, the way the test's history step needs.
fn add_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, duplicate: bool) {
if duplicate {
let commands = app.run_count;
artboard_deletion_click(app, ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE);
assert_eq!(app.run_count, commands.wrapping_add(1));
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3);
assert_eq!(app.session.tool_options()["active"], json!(2));
assert_eq!(app.view().unwrap().artboard, 2);
} else {
artboard_history_new(app, ctx);
}
}
fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
let mut app = artboard_deletion_app(2);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough");
let undo = st.history.undo.len();
let ctx = egui::Context::default();
add_board(&mut app, &ctx, duplicate);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before, "the real Undo shortcut restores the original document");
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
delete_again_after_undo(&mut app, &ctx, key, &before, &selection, undo);
}
/// Delete the normalized shown board after Undo, then verify its own Undo and Redo.
fn delete_again_after_undo(
app: &mut crate::VectorcraftApp,
ctx: &egui::Context,
key: Option<egui::Key>,
before: &std::sync::Arc<vectorcraft_doc::Document>,
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
// Capture the state before another panel or pointer can repair it. Still exercise deletion
// on RED: keyboard deletion used to do nothing; Properties already clamped its own target.
let after_undo = (app.view().unwrap().artboard, app.session.tool_options());
if let Some(key) = key {
artboard_history_key(app, ctx, key, egui::Modifiers::NONE);
} else {
let texts = panel_frame(app, ctx, vec![], show);
assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name));
artboard_deletion_click(app, ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE);
}
let st = app.session.doc().unwrap();
assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board");
assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes");
assert_eq!(&st.selection, selection, "deletion never falls through to Clear");
assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture");
assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true}));
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard");
assert!(st.is_dirty());
assert_eq!(app.view().unwrap().artboard, 0);
assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true}));
let deleted = st.doc.clone();
artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.as_ref(), before.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
// Valid current selection stays put; history does not restore an earlier artboard selection.
assert_eq!(app.session.tool_options()["selected"], json!([0]));
artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
let st = app.session.doc().unwrap();
assert_eq!(st.doc.as_ref(), deleted.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert!(st.is_dirty());
}
#[test]
fn artboard_history_new_undo_delete_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Delete), false);
}
#[test]
fn artboard_history_new_undo_backspace_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Backspace), false);
}
#[test]
fn artboard_history_new_undo_properties_agrees_with_tool() {
artboard_history_delete_after_undo(None, false);
}
#[test]
fn artboard_history_duplicate_undo_delete_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Delete), true);
}
#[test]
fn artboard_history_undo_prunes_only_removed_indices() {
for order in [[0, 1, 2], [0, 2, 1]] {
let mut app = artboard_deletion_app(2);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
let ctx = egui::Context::default();
artboard_history_new(&mut app, &ctx);
let created = app.session.doc().unwrap().doc.clone();
for (at, i) in order.into_iter().enumerate() {
let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::COMMAND };
artboard_deletion_click(&mut app, &ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers);
}
let options = app.session.tool_options();
let expected = if order[2] == 2 { json!([0, 1]) } else { json!([1, 0]) };
assert_eq!(options["selected"], if order[2] == 2 { json!([2, 0, 1]) } else { json!([1, 0, 2]) });
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let mut normalized = options;
normalized["active"] = expected[0].clone();
normalized["selected"] = expected;
assert_eq!(app.session.tool_options(), normalized, "only the removed index is pruned, preserving surviving order and options");
assert_eq!(json!(app.view().unwrap().artboard), normalized["active"]);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
assert_eq!(app.session.doc().unwrap().doc, created);
assert_eq!(app.session.doc().unwrap().selection, selection);
assert_eq!(app.session.tool_options(), normalized, "Redo does not replace an already valid multiselection");
}
}
#[test]
fn artboard_history_valid_multiselection_survives_undo_redo() {
let mut app = artboard_deletion_app(3);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &[0, 1]);
let options = app.session.tool_options();
assert_eq!(options["selected"], json!([1, 0]));
let view = app.view().unwrap().artboard;
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap();
let moved = app.session.doc().unwrap().doc.clone();
assert_ne!(moved, before);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
app.sync_views();
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
assert_eq!(app.session.tool_options(), options);
assert_eq!(app.view().unwrap().artboard, view);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
app.sync_views();
let st = app.session.doc().unwrap();
assert_eq!(st.doc, moved);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert!(st.is_dirty());
assert_eq!(app.session.tool_options(), options);
assert_eq!(app.view().unwrap().artboard, view);
}
/// #681: Rearrange All opens Rearrange All Artboards in one click, from the Document section
/// (only with several artboards) and from Edit Artboards' Quick Actions.
#[test]
fn rearrange_all_opens_the_dialog() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Rearrange All"), "one artboard: nothing to rearrange");
app.run("artboard.new", json!({})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Rearrange All");
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND));
app.ui.dialog = None;
app.select_tool("artboard");
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Rearrange All");
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND));
}
/// #671: Edit Artboards shows the artboard's preset (Custom when it matches none) and its
/// orientation; the other orientation swaps its width and height, its corner staying put.
#[test]
fn edit_artboards_shows_the_preset_and_flips_the_orientation() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"preset": "Letter"})).unwrap();
app.run("artboard.setProps", json!({"index": 0, "x": 10, "y": 20})).unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Preset:") && texts.iter().any(|(t, _)| t == "Letter"), "{texts:?}");
// The row's clickable widgets, left to right: the preset combo, Portrait, Landscape.
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
let buttons: Vec<Rect> = ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r
});
let landscape = buttons.last().unwrap().center();
let press = |pressed| Event::PointerButton { pos: landscape, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(landscape), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
let rect = app.session.active().unwrap().doc.artboards[0].rect;
assert_eq!((rect.x0, rect.y0, rect.width(), rect.height()), (10.0, 20.0, 792.0, 612.0), "landscape, same corner");
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Letter"), "still Letter, turned: {texts:?}");
app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap();
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom"));
}
/// The center of Edit Artboards' Portrait (`landscape` false) or Landscape button.
fn orientation_button(ctx: &egui::Context, texts: &[(String, Rect)], landscape: bool) -> Pos2 {
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r[r.len() - if landscape { 1 } else { 2 }].center()
})
}
/// #1482: with Artboards 1 and 3 selected, Portrait swaps the width and height of both in one
/// undo step, each keeping its top-left corner; Artboard 2 stays landscape. An artboard already
/// in that orientation keeps its size.
#[test]
fn edit_artboards_orientation_turns_every_selected_artboard() {
let mut app = two_selected_artboards();
let before = boards(&app);
let undo = app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
let portrait = |app: &mut VectorcraftApp| {
frame(app, &ctx, vec![]);
let texts = frame(app, &ctx, vec![]);
let at = orientation_button(&ctx, &texts, false);
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![Event::PointerMoved(at), press(true)]);
frame(app, &ctx, vec![press(false)]);
};
portrait(&mut app);
let mut want = before.clone();
for i in [0, 2] {
(want[i].2, want[i].3) = (before[i].3, before[i].2);
}
assert_eq!(boards(&app), want);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(boards(&app), before);
// Artboard 3 portrait already: Portrait turns Artboard 1 alone.
app.run("artboard.setProps", json!({"index": 2, "width": 150, "height": 300})).unwrap();
let before = boards(&app);
portrait(&mut app);
let mut want = before.clone();
(want[0].2, want[0].3) = (100.0, 200.0);
assert_eq!(boards(&app), want);
}
/// #1482: with Artboards 1 and 3 selected, a preset chosen in Edit Artboards sizes both in one
/// undo step, each in its own orientation and keeping its top-left corner: Letter (612 × 792 pt)
/// stays portrait for Artboard 1 and turns landscape for Artboard 3. Artboard 2 keeps its size.
#[test]
fn edit_artboards_preset_sizes_every_selected_artboard() {
let mut app = two_selected_artboards();
app.run("artboard.setProps", json!({"index": 0, "width": 100, "height": 200})).unwrap();
let before = boards(&app);
let undo = app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
// A frame on a wide screen, with the texts of nested shapes too (the open list and its
// submenu), and the center of `label` among them (menu items carry a check-mark column).
let run = |app: &mut VectorcraftApp, events: Vec<Event>| {
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 raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
};
let at = |texts: &[(String, Rect)], label: &str| {
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
};
let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
run(&mut app, vec![]);
let custom = at(&run(&mut app, vec![]), "Custom");
run(&mut app, vec![Event::PointerMoved(custom), press(custom, true)]);
run(&mut app, vec![press(custom, false)]);
// Hovering Print opens its submenu.
let print = at(&run(&mut app, vec![]), "Print");
run(&mut app, vec![Event::PointerMoved(print)]);
let letter = at(&run(&mut app, vec![]), "Letter");
run(&mut app, vec![Event::PointerMoved(letter), press(letter, true)]);
run(&mut app, vec![press(letter, false)]);
let mut want = before.clone();
(want[0].2, want[0].3) = (612.0, 792.0);
(want[2].2, want[2].3) = (792.0, 612.0);
assert_eq!(boards(&app), want);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
/// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size
/// (here the other orientation) takes the art on the artboard along.
#[test]
fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}");
assert_eq!(app.session.tool_options()["scaleArt"], false);
click(&mut app, &ctx, &texts, "Scale Artwork with Artboard");
assert_eq!(app.session.tool_options()["scaleArt"], true);
// Portrait: 100 × 200, the art scaled by ½ across and 2 down.
let texts = frame(&mut app, &ctx, vec![]);
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
let portrait = ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r[r.len() - 2].center()
});
let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
let d = &app.session.active().unwrap().doc;
let ab = d.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (100.0, 200.0));
let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0));
}
/// One frame of the panel 1600 points tall, so Quick Actions at its foot are laid out, with
/// `events` → the texts painted, with their rects.
fn tall(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
_ => None,
})
.collect()
}
/// Clicks `at` in the tall panel over two frames (press, release), then lets a popup it opens
/// lay out → the texts of the frame after.
fn press(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) -> Vec<(String, Rect)> {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
tall(app, ctx, vec![Event::PointerMoved(at), b(true)]);
tall(app, ctx, vec![b(false)]);
tall(app, ctx, vec![]);
tall(app, ctx, vec![])
}
/// Where the tall panel painted `label`.
fn text_at(texts: &[(String, Rect)], label: &str) -> Rect {
texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
}
fn shows(texts: &[(String, Rect)], label: &str) -> bool {
texts.iter().any(|(t, _)| t == label)
}
fn undo_depth(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().history.undo.len()
}
fn new_app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> serde_json::Value {
app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].clone()
}
/// One blend selected with the Blend tool has Blend Options in Quick Actions, which opens the
/// Blend Options dialog on it; OK is one undo step. Not with another tool, nor for a blend
/// selected with another object.
#[test]
fn blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool() {
let mut app = new_app();
let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0));
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let blend = app.run("object.blend.make", json!({})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [blend]})).unwrap();
let ctx = egui::Context::default();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "not with the Selection tool");
app.select_tool("blend");
app.run("select.set", json!({"ids": [blend]})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, "Blend Options").center());
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::blend_options::KIND));
assert_eq!(undo_depth(&app), depth, "opening the dialog changes nothing");
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("steps"));
d.fields.insert("steps".into(), json!(3));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(undo_depth(&app), depth + 1, "OK is one undo step");
let c = rect(&mut app, 20.0, 200.0, 40.0, 40.0);
app.select_tool("blend");
app.run("select.set", json!({"ids": [blend, c]})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a blend and another object");
app.run("select.set", json!({"ids": [c]})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a rectangle");
}
/// Quick Actions have no Lock in isolation mode, where Object › Lock › Selection is off. Outside
/// isolation mode Lock is there and locks the selection.
#[test]
fn lock_is_a_quick_action_outside_isolation_mode_only() {
let mut app = new_app();
let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0));
app.run("select.set", json!({"ids": [a.clone(), b]})).unwrap();
let g = app.run("object.group", json!({})).unwrap()["id"].clone();
app.run("object.isolate", json!({"id": g.clone()})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let ctx = egui::Context::default();
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Offset Path") && !shows(&texts, "Lock"), "no Lock in isolation mode: {texts:?}");
app.run("object.exitIsolation", json!({})).unwrap();
app.run("select.set", json!({"ids": [g.clone()]})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
press(&mut app, &ctx, text_at(&texts, "Lock").center());
let locked = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(g.as_u64().unwrap())).unwrap().locked;
assert!(locked, "Lock locks the group");
}
/// A Live Paint group selected with a path has Merge Live Paint in Quick Actions, which adds
/// the path to the group in one undo step. Not for the group alone, nor for plain paths, nor
/// with type selected, which Live Paint refuses.
#[test]
fn merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more() {
let mut app = new_app();
let (a, b) = (rect(&mut app, 20.0, 20.0, 100.0, 60.0), rect(&mut app, 60.0, 40.0, 100.0, 60.0));
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let group = app.run("livePaint.make", json!({})).unwrap()["id"].clone();
let (c, d) = (rect(&mut app, 140.0, 30.0, 60.0, 60.0), rect(&mut app, 300.0, 200.0, 40.0, 40.0));
let text = app.run("text.create", json!({"x": 50, "y": 250, "text": "Hi"})).unwrap()["id"].clone();
let ctx = egui::Context::default();
for (ids, why) in [
(json!([group]), "the group alone"),
(json!([c, d]), "two plain paths"),
(json!([group, text]), "the group and type"),
(json!([group, c, text]), "the group, a path and type"),
] {
app.run("select.set", json!({"ids": ids})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Merge Live Paint"), "{why}");
}
app.run("select.set", json!({"ids": [group, c]})).unwrap();
let c_id = vectorcraft_doc::NodeId(c.as_u64().unwrap());
let parent = app.session.active().unwrap().doc.parent_of(c_id);
let texts = tall(&mut app, &ctx, vec![]);
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, "Merge Live Paint").center());
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
let st = app.session.active().unwrap();
assert_eq!(st.selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>(), std::slice::from_ref(&group));
assert!(st.doc.node(c_id).is_none() || st.doc.parent_of(c_id) != parent, "the path is merged into the group");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.parent_of(c_id), parent, "Undo takes it out again");
}
/// A selected group has Ungroup and Isolate Group in Quick Actions. While an opacity mask is
/// edited, when Enter Isolation Mode is off, a group in the mask art has Ungroup only.
#[test]
fn isolate_group_is_not_a_quick_action_while_an_opacity_mask_is_edited() {
let mut app = new_app();
let group = |app: &mut VectorcraftApp, x: f64| {
let (a, b) = (rect(app, x, 20.0, 40.0, 40.0), rect(app, x + 50.0, 20.0, 40.0, 40.0));
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("object.group", json!({})).unwrap();
};
let ctx = egui::Context::default();
group(&mut app, 20.0);
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Ungroup") && shows(&texts, "Isolate Group"), "{texts:?}");
let (obj, m) = (rect(&mut app, 200.0, 100.0, 80.0, 80.0), rect(&mut app, 200.0, 100.0, 40.0, 80.0));
app.run("transparency.makeOpacityMask", json!({"ids": [obj, m]})).unwrap();
app.run("transparency.editOpacityMask", json!({"id": obj})).unwrap();
group(&mut app, 200.0);
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Ungroup") && !shows(&texts, "Isolate Group"), "{texts:?}");
}
/// A stroke with a variable width shows its Width Profile in the Appearance section, as every
/// stroke does with the Width tool; choosing another profile is one undo step. A uniform stroke
/// shows none with other tools.
#[test]
fn the_width_profile_shows_for_a_variable_width_or_the_width_tool() {
let mut app = new_app();
let line = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 300, "y2": 100})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [line]})).unwrap();
let ctx = egui::Context::default();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "a uniform stroke");
app.select_tool("width");
app.run("select.set", json!({"ids": [line]})).unwrap();
assert!(shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "the Width tool");
app.select_tool("selection");
app.run("select.set", json!({"ids": [line]})).unwrap();
app.run("stroke.set", json!({"profile": "lens"})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
// The list sits right of its label.
let spacing = ctx.global_style().spacing.item_spacing.x;
let texts = press(&mut app, &ctx, text_at(&texts, "Profile:").right_center() + vec2(spacing + 50.0, 0.0));
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, "Taper Start").center());
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
let profile =
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(line.as_u64().unwrap())).unwrap().appearance.stroke().unwrap().profile.clone();
assert_eq!(WidthProfile::id_of(profile.as_ref()), "taperStart");
}
/// With Direct Selection, a path whose corner is selected shows that corner's kind and radius;
/// each edit sets that corner alone in one undo step. Not with the Selection tool, nor with the
/// whole path selected; a live rectangle keeps its own Corner Radius row instead.
#[test]
fn direct_selected_corners_show_their_kind_and_radius() {
let mut app = new_app();
let star = app.run("shape.star", json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap()["id"].as_u64().unwrap();
let ctx = egui::Context::default();
app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "not with the Selection tool");
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Corner:") && shows(&texts, "Corner Radius:"), "{texts:?}");
let corners = |app: &VectorcraftApp| {
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(star)).unwrap().clone();
let lc = LiveCorners::of(&n).unwrap();
(lc.radius(0), lc.radius(2), lc.kind(0), lc.kind(2))
};
// The radius: the field right of its label.
let label = text_at(&texts, "Corner Radius:");
let field = texts.iter().find(|(t, r)| t == "0 pt" && r.left() > label.right() && (r.center().y - label.center().y).abs() < 6.0).unwrap().1;
press(&mut app, &ctx, field.center());
let depth = undo_depth(&app);
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
tall(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
tall(&mut app, &ctx, vec![Event::Text("6".into())]);
tall(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
let texts = tall(&mut app, &ctx, vec![]);
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
let round = vectorcraft_geom::shapes::CornerKind::Round;
assert_eq!(corners(&app), (0.0, 6.0, round, round));
// The kind, from its list.
let texts = press(&mut app, &ctx, text_at(&texts, "Round").center());
press(&mut app, &ctx, text_at(&texts, "Chamfer").center());
assert_eq!(undo_depth(&app), depth + 2, "one undo step");
assert_eq!(corners(&app), (0.0, 6.0, round, vectorcraft_geom::shapes::CornerKind::Chamfer));
// The whole path selected.
app.run("select.set", json!({"ids": [star]})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "no corner selected");
// A live rectangle's corner: its own Corner Radius row, once.
let r = rect(&mut app, 20.0, 20.0, 60.0, 40.0).as_u64().unwrap();
app.run("select.anchors", json!({"id": r, "anchors": [[0, 1]], "mode": "set"})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
assert!(!shows(&texts, "Corner:"), "{texts:?}");
assert_eq!(texts.iter().filter(|(t, _)| t == "Corner Radius:").count(), 1, "{texts:?}");
}
/// One effect on the one selected object shows its name and a Delete icon beside the fx button,
/// which removes it in one undo step, the object's own or its stroke's. With several effects the
/// icon opens the Appearance panel; without one, or with several objects selected, no name.
#[test]
fn one_effect_has_a_delete_icon_in_appearance() {
let mut app = new_app();
let id = rect(&mut app, 20.0, 20.0, 80.0, 60.0);
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
app.run("select.set", json!({"ids": [id]})).unwrap();
let ctx = egui::Context::default();
let spacing = ctx.global_style().spacing.item_spacing.x;
let effects = |app: &VectorcraftApp| {
let n = app.session.active().unwrap().doc.node(node).unwrap().clone();
n.appearance.effects.len() + n.appearance.items.iter().map(|i| i.effects().len()).sum::<usize>()
};
let name = super::super::appearance::shown_effect_label("stylize.dropShadow");
assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "no effect");
// The object's own effect.
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0));
assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "removed in one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(effects(&app), 1);
// The stroke's effect.
app.run("edit.undo", json!({})).unwrap();
let stroke = app.session.active().unwrap().doc.node(node).unwrap().appearance.items.iter().position(|i| !i.is_fill()).unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0));
assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "the stroke's effect removed in one undo step");
// Two effects: the icon opens the Appearance panel.
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap();
let texts = tall(&mut app, &ctx, vec![]);
assert!(!shows(&texts, &name), "{texts:?}");
let depth = undo_depth(&app);
press(&mut app, &ctx, text_at(&texts, "fx").center() + vec2(17.0 + spacing + 11.0, 0.0));
assert_eq!(app.ui.open_panel.as_deref(), Some("appearance"));
assert_eq!((effects(&app), undo_depth(&app)), (2, depth));
// Two objects with one effect each: no name.
app.run("edit.undo", json!({})).unwrap();
let other = rect(&mut app, 200.0, 20.0, 40.0, 40.0);
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
app.run("select.set", json!({"ids": [id, other]})).unwrap();
assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "two objects");
}
/// A rectangle at 100, 100, 100 × 60 pt, selected, with `then` run on it, and a context that
/// builds the accessibility tree (to find the locator's squares).
fn reference_setup(then: &[(&str, Value)]) -> (VectorcraftApp, egui::Context) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
for (id, p) in then {
app.run(id, p.clone()).unwrap();
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
(app, ctx)
}
/// Clicks the topmost control of `draw` that screen readers call `name`.
fn click_named(app: &mut VectorcraftApp, ctx: &egui::Context, draw: fn(&mut VectorcraftApp, &mut Ui), name: &str) {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui));
out.textures_delta.clear();
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let b = nodes
.iter()
.filter(|(_, n)| n.label() == Some(name))
.filter_map(|(_, n)| n.bounds())
.min_by(|a, b| a.y0.total_cmp(&b.y0))
.unwrap_or_else(|| panic!("no {name:?}"));
let at = egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32);
crate::panels::tests_appearance::click(ctx, app, at, draw);
}
/// Types `value` over the topmost field of `draw` that shows `shown`, and presses Enter.
fn type_over(app: &mut VectorcraftApp, ctx: &egui::Context, draw: fn(&mut VectorcraftApp, &mut Ui), shown: &str, value: &str) {
use crate::panels::tests_appearance::{click, frame_events, text_rect};
let texts = frame_events(ctx, app, vec![], draw);
click(ctx, app, text_rect(&texts, shown).center(), draw);
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
frame_events(ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
frame_events(ctx, app, vec![Event::Text(value.into())], draw);
frame_events(ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
frame_events(ctx, app, vec![], draw);
}
/// The Properties panel with the Transform panel below it, as when both are open.
fn properties_and_transform(app: &mut VectorcraftApp, ui: &mut Ui) {
show(app, ui);
ui.separator();
crate::panels::transform::show(app, ui);
}
/// A panel (or panels) drawn in a test frame.
type Draw = fn(&mut VectorcraftApp, &mut Ui);
fn near(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool {
(a - b).hypot() < 1e-6
}
/// #4285: with the locator's top-left square chosen, a rotation typed in the Properties panel's
/// Transform section turns the selection about the box's top-left corner, as the Transform panel
/// does: on a plain rectangle, on one already turned 30°, and with the Transform panel open too.
#[test]
fn a_typed_rotation_turns_about_the_reference_point() {
// (case, the angle the rectangle starts at, the panels, the angle typed)
let cases: [(&str, f64, Draw, &str); 4] = [
("section", 0.0, transform_section, "45"),
("panel", 0.0, show, "45"),
("turned 30°", 30.0, transform_section, "60"),
("with the Transform panel", 0.0, properties_and_transform, "45"),
];
for (case, start, draw, value) in cases {
let (mut app, ctx) = reference_setup(&[("object.rotate", json!({"angle": start}))]);
let shown = format!("{start}°");
click_named(&mut app, &ctx, draw, "Top Left");
assert_eq!(ctx.data(|d| d.get_temp::<usize>(egui::Id::unique("refpt"))), Some(0), "{case}: the locator's choice");
let corner = app.selection_box().unwrap().reference_point(0);
type_over(&mut app, &ctx, draw, &shown, value);
let bx = app.selection_box().unwrap();
assert!((bx.angle - value.parse::<f64>().unwrap()).abs() < 1e-6, "{case}: turned to {}", bx.angle);
assert!(near(bx.reference_point(0), corner), "{case}: the top-left corner {corner:?} moved to {:?}", bx.reference_point(0));
}
}
/// #4285: the flip buttons mirror the selection about the chosen reference point: with the
/// top-left square, across the box's left edge (left-right) or top edge (top-bottom).
#[test]
fn the_flip_buttons_mirror_about_the_reference_point() {
for (button, want) in [("Flip Along Horizontal Axis", (0.0, 100.0, 100.0, 160.0)), ("Flip Along Vertical Axis", (100.0, 40.0, 200.0, 100.0))] {
let (mut app, ctx) = reference_setup(&[]);
click_named(&mut app, &ctx, transform_section, "Top Left");
click_named(&mut app, &ctx, transform_section, button);
let r = app.selection_box().unwrap().rect;
let got = (r.x0, r.y0, r.x1, r.y1);
assert!([got.0 - want.0, got.1 - want.1, got.2 - want.2, got.3 - want.3].iter().all(|d| d.abs() < 1e-6), "{button}: {got:?}, want {want:?}");
}
}
/// With Use Registration Point for Symbol on, one symbol instance turns about its symbol's
/// registration point (here the bottom-right one) whatever the locator shows: the locator
/// follows the registration point.
#[test]
fn a_symbol_instance_turns_about_its_registration_point() {
let (mut app, ctx) = reference_setup(&[]);
let id = app.run("symbol.new", json!({"name": "Corner", "registration": 8})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.session.prefs.use_registration_point_for_symbol = true;
click_named(&mut app, &ctx, transform_section, "Top Left");
type_over(&mut app, &ctx, transform_section, "0°", "45");
let bx = app.selection_box().unwrap();
assert!((bx.angle - 45.0).abs() < 1e-6, "turned to {}", bx.angle);
assert!(near(bx.reference_point(8), vectorcraft_geom::Point::new(200.0, 160.0)), "the registration point moved to {:?}", bx.reference_point(8));
}
/// Ruler guides selected on their own (a click on one) show a Guides heading and a Transform
/// section, where X moves the vertical ones and Y the horizontal ones; with art selected too, the
/// panel shows the art's sections.
#[test]
fn selected_guides_show_their_position_in_a_transform_section() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let v = app.run("guide.add", json!({"vertical": true, "pos": 30})).unwrap()["index"].as_u64().unwrap();
let h = app.run("guide.add", json!({"vertical": false, "pos": 20})).unwrap()["index"].as_u64().unwrap();
let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.guides[i as usize].pos;
let ctx = egui::Context::default();
// Steps the field labelled `label` up by one with ↑.
let step = |app: &mut VectorcraftApp, label: &str| {
let texts = frame(app, &ctx, vec![]);
let r = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1;
let at = egui::pos2(r.right() + 40.0, r.center().y);
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![Event::PointerMoved(at), press(true)]);
frame(app, &ctx, vec![press(false)]);
let up = Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(app, &ctx, vec![up]);
frame(
app,
&ctx,
vec![Event::PointerButton { pos: egui::pos2(5.0, 690.0), button: PointerButton::Primary, pressed: true, modifiers: Default::default() }],
);
};
for (index, label, before) in [(v, "X:", 30.0), (h, "Y:", 20.0)] {
app.run("guide.select", json!({"indexes": [index]})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect();
for shown in ["Guides", "Transform", "X:", "Y:"] {
assert!(texts.iter().any(|t| t == shown), "{shown:?} in {texts:?}");
}
assert!(!texts.iter().any(|t| t == "Document"), "{texts:?}");
step(&mut app, label);
assert_eq!((pos(&app, index) - before).abs(), 1.0, "{label} moves the selected guide");
}
// A guide and a rectangle: the rectangle's sections.
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("guide.select", json!({"indexes": [v], "toggle": true})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect();
assert!(!texts.iter().any(|t| t == "Guides") && texts.iter().any(|t| t == "W:"), "{texts:?}");
}