Files
vectorcraft/crates/ui-egui/src/dialogs/tests.rs
T
Taha-Mahmoodi 64f6d2db50 Artboards: Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas
The Artboard Options help pages describe two display options
(https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html,
.../set-video-display-options-for-artboards.html): a mark at the artboard's
center, and lines from the middle of each side through the center. The
document already had show_center_mark and show_cross_hairs on Artboard, but
nothing set or drew them.

artboard.setProps (each `artboards` entry too) and artboard.new take
showCenterMark and showCrossHairs as true/false, in the same single undo
step as the other fields; a locked artboard can still change them.
Artboard Options has both checkboxes, filled in from the artboard whether
it opens from the panel or a double-click with the Artboard tool, and its
New Artboard form passes them to artboard.new. The canvas draws them in the
new theme token artboard_mark, 1 point wide on screen at any zoom: a cross
12 points across at the center whose arms follow the artboard's sides, and
the two cross hairs. They are screen-only like the edges; exports and print
never read the fields. inspect_document reports them when on. Saved files
are unchanged: the two keys were already always written, so a document that
never shows them saves byte for byte as before, and a file without the keys
opens with both off. Video safe areas, video rulers and the pixel aspect
ratio are not part of this change.

Verification: artboard_display_marks_are_set_and_cleared_in_one_undo_step,
artboard_display_marks_round_trip_through_the_native_format,
artboard_display_marks_stay_out_of_exports (SVG and PDF identical with the
marks on and off), an_artboard_shows_its_center_mark_and_cross_hairs
(positions, screen-constant size under zoom, rotated view) and
artboard_options_show_center_mark_and_cross_hairs. cargo xtask ci: all 10
steps passed.
2026-10-23 09:42:09 +07:00

2330 lines
118 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.
//! The dialog registry and the shared frame, drawn headlessly.
use serde_json::json;
use vectorcraft_doc::Unit;
use vectorcraft_engine::Session;
use super::*;
use crate::theme;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp, input: egui::RawInput) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn enter() -> egui::RawInput {
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
egui::RawInput { events: vec![key], ..Default::default() }
}
fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) {
app.ui.dialog = Some(Dialog::new(kind, fields));
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
}
#[test]
fn kinds_map_to_their_dialogs() {
for (kind, want) in [
("newDocument", DialogKind::NewDocument),
("roundedRectangle", DialogKind::Shape),
("lineSegment", DialogKind::Shape),
("arc", DialogKind::Shape),
("rectangularGrid", DialogKind::Shape),
("polarGrid", DialogKind::Shape),
("shear", DialogKind::Transform),
("splitIntoGrid", DialogKind::PathOp),
("artboardOptions", DialogKind::ArtboardOptions),
("command", DialogKind::Command),
("effect", DialogKind::Effect),
(crate::unsaved::KIND, DialogKind::SaveChanges),
("manageWorkspaces", DialogKind::Workspaces),
("findFont", DialogKind::FindFont),
] {
assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
}
assert_eq!(DialogKind::of("nope"), None);
assert!(spec("effect").preview && spec("recolor").preview && !spec("rectangle").preview);
assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
}
#[test]
fn every_dialog_draws() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let openers: [(&str, serde_json::Value); 8] = [
("file.newDialog", json!({})),
("file.documentSetup", json!({})),
("file.exportForScreens", json!({})),
("ui.recolorDialog", json!({})),
("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2, "csv": "a\nb"}})),
("effect.dialog", json!({"effect": "distort.roughen"})),
("edit.preferences", json!({})),
("type.findFont", json!({})),
];
for (id, p) in openers {
app.run(id, p).unwrap();
assert!(app.ui.dialog.is_some(), "{id} opened nothing");
frame(&mut app, Default::default());
app.ui.dialog = None;
let _ = app.session.cancel_interaction();
}
for kind in [
"rectangle",
"roundedRectangle",
"ellipse",
"polygon",
"star",
"lineSegment",
"arc",
"rectangularGrid",
"polarGrid",
"rotate",
"reflect",
"scale",
"shear",
"artboardOptions",
] {
open_tool_dialog(&mut app, kind, json!({"x": 5, "y": 5, "index": 0}));
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == kind), "{kind}");
frame(&mut app, Default::default());
}
for (kind, fields) in [
("move", json!({"dx": "0 pt", "dy": "0 pt"})),
("average", json!({"axis": "both"})),
(crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})),
("newWorkspace", json!({"name": "Mine"})),
("shortcuts", json!({})),
("unregistered", json!({"value": "1"})),
] {
open(&mut app, kind, fields);
frame(&mut app, Default::default());
}
}
#[test]
fn enter_confirms_through_the_dialog_spec() {
let mut app = app();
open_tool_dialog(&mut app, "rectangle", json!({"x": 10, "y": 20}));
frame(&mut app, enter());
assert!(app.ui.dialog.is_none());
assert_eq!(objects(&app), 1);
// An unregistered kind still closes on OK without running anything.
open(&mut app, "unregistered", json!({}));
assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null));
assert!(app.ui.dialog.is_none());
assert_eq!(confirm(&mut app), Err("no dialog open".into()));
}
#[test]
fn confirm_runs_each_dialogs_command() {
let mut app = app();
open(&mut app, "star", json!({"x": 50, "y": 50, "radius1": "40 pt", "radius2": "20 pt", "points": 6}));
confirm(&mut app).unwrap();
open(&mut app, "lineSegment", json!({"x": 0, "y": 0, "length": "30 pt", "angle": 90}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 2);
app.run("select.all", json!({})).unwrap();
// Scale with Uniform uses the horizontal value for both axes; Copy keeps the original.
open(&mut app, "scale", json!({"sx": 200, "sy": 50, "uniform": true, "copy": true}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 4);
// The parameter dialog closes before its command runs, then runs it with the edited values.
app.run("ui.paramDialog", json!({"command": "view.saved.new", "label": "New View", "params": {"name": "Close-up"}})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.views.last().map(|v| v.name.as_str()), Some("Close-up"));
// New Document creates the document and closes.
let docs = app.session.documents().len();
open(
&mut app,
"newDocument",
json!({"width": "300 pt", "height": "200 pt", "units": "Points", "name": "Card", "artboards": 1, "colorMode": "RGB"}),
);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), docs + 1);
}
#[test]
fn effect_preview_is_kept_as_one_undo_step() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app, Default::default());
assert!(app.session.in_interaction(), "the preview runs as an interaction");
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.last_effect.as_ref().map(|e| e.0.as_str()), Some("distort.roughen"));
assert_ne!(app.session.doc().unwrap().doc, before);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// Drop Shadow's Color typed as text the effect can't draw: OK returns the error and keeps the
/// dialog open, with Preview on or off, and the document stays as it was.
#[test]
fn effect_dialog_refuses_a_color_it_cannot_draw() {
for preview in [true, false] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "stylize.dropShadow"})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("preview".into(), json!(preview));
d.fields.insert("color".into(), json!("not-a-color"));
frame(&mut app, Default::default());
let e = confirm(&mut app).unwrap_err();
assert!(e.contains("`color` must be a #rrggbb color"), "preview {preview}: {e}");
assert!(app.ui.dialog.is_some() && app.last_effect.is_none(), "preview {preview}");
assert_eq!(app.session.doc().unwrap().doc, before, "preview {preview}");
}
}
#[test]
fn raster_filter_dialogs_preview_and_apply() {
for (effect, key, value) in [
("artistic.cutout", "levels", "6"),
("artistic.neonGlow", "color", "#ff00ff"),
("artistic.posterEdges", "edgeThickness", "5"),
("artistic.smudgeStick", "strokeLength", "6"),
("artistic.sponge", "definition", "20"),
("artistic.watercolor", "texture", "2"),
("blur.radial", "method", "zoom"),
("blur.smart", "quality", "high"),
("blur.smart", "blurMode", "overlayEdge"),
("sharpen.unsharpMask", "amount", "120"),
("pixelate.colorHalftone", "channel1", "30"),
("pixelate.crystallize", "cellSize", "20"),
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("sketch.basRelief", "lightDirection", "left"),
("sketch.charcoal", "thickness", "5"),
("sketch.conteCrayon", "texture", "brick"),
("sketch.conteCrayon", "foregroundLevel", "16"),
("sketch.halftonePattern", "patternType", "line"),
("sketch.photocopy", "darkness", "40"),
("sketch.stamp", "lightDarkBalance", "10"),
("sketch.plaster", "lightDirection", "bottomLeft"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
("texture.patchwork", "relief", "20"),
("texture.stainedGlass", "borderThickness", "2"),
("texture.texturizer", "lightDirection", "bottomLeft"),
] {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": effect})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
let v: serde_json::Value = value.parse::<f64>().map_or_else(|_| json!(value), |n| json!(n));
d.fields.insert(key.into(), v.clone());
frame(&mut app, Default::default());
assert!(app.session.in_interaction(), "{effect}: previews");
confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.effects.len(), 1, "{effect}");
assert_eq!(n.appearance.effects[0].params[key], v, "{effect}");
}
// Fields follow the effect's documented order (its dialog's), not the alphabet.
let doc = vectorcraft_effects::effect_info("blur.smart").unwrap().params;
let ranks: Vec<usize> = ["radius", "threshold", "quality", "blurMode"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[3] < usize::MAX, "{ranks:?}");
assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX);
let doc = vectorcraft_effects::effect_info("pixelate.colorHalftone").unwrap().params;
let ranks: Vec<usize> = ["maxRadius", "channel1", "channel2", "channel3", "channel4"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
// Labels as the dialogs show them.
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
assert_eq!(super::form::humanize("blurMode"), "Mode:");
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
assert_eq!(super::form::humanize("lightDirection"), "Light Direction:");
let doc = vectorcraft_effects::effect_info("texture.texturizer").unwrap().params;
let ranks: Vec<usize> = ["texture", "scaling", "relief", "lightDirection", "invert"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
}
#[test]
fn effect_dialog_applies_to_its_appearance_item() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.roughen", "item": 1})).unwrap();
frame(&mut app, Default::default());
confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.items[1].effects().len(), 1, "the effect goes to the stroke");
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
}
#[test]
fn scribble_dialog_shows_the_angle_as_stored_and_each_variation() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "stylize.scribble"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(270));
frame(&mut app, Default::default());
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["angle"].as_f64(), Some(-90.0), "the field reads what is stored");
confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.effects[0].params["angle"].as_f64(), Some(-90.0));
// Each variation follows the option it varies, as in the reference app's Scribble Options.
let doc = vectorcraft_effects::effect_info("stylize.scribble").unwrap().params;
let order = ["angle", "overlap", "overlapVariation", "strokeWidth", "curviness", "curvinessVariation", "spacing", "spacingVariation", "seed"];
let ranks: Vec<usize> = order.iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[8] < usize::MAX, "{ranks:?}");
for key in ["overlapVariation", "curvinessVariation", "spacingVariation"] {
assert_eq!(super::form::humanize(key), "Variation:");
}
}
#[test]
fn revolve_dialog_preview_cancel_confirm_and_undo() {
let mut app = app();
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
frame(&mut app, Default::default());
assert!(app.session.in_interaction());
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180));
frame(&mut app, Default::default());
confirm(&mut app).unwrap();
assert_ne!(app.session.doc().unwrap().doc, before);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn extrude_inflate_and_rotate_dialogs_preview_confirm_and_undo() {
for effect in vectorcraft_effects::SOLIDS {
let mut app = app();
app.run("path.create", json!({"d":"M100 100 L200 100 L200 200 L100 200 Z"})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": effect, "item": null})).unwrap();
frame(&mut app, Default::default());
assert!(app.session.in_interaction(), "{effect}: previewing");
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before, "{effect}: Cancel restores");
app.run("effect.dialog", json!({"effect": effect, "item": null})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rotationY".into(), json!(45));
frame(&mut app, Default::default());
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
let n = doc.node(doc.layers[0].children().unwrap()[0].id).unwrap();
assert_eq!(n.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [effect]);
assert_eq!(n.appearance.effects[0].params["rotationY"], json!(45));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before, "{effect}: one undo step");
}
}
#[test]
fn dialogs_are_as_tall_as_their_content() {
// The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty
// band around the buttons, nothing inherited from the other dialog.
let mut app = app();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let height = |app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value| {
open(app, kind, fields);
for _ in 0..30 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
}
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind)))).unwrap().height()
};
let tall = height(&mut app, "command", json!({"__command": "object.move", "a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6, "g": 7, "h": 8}));
let prompt = height(&mut app, crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"}));
assert!(prompt < 180.0 && prompt < tall, "save prompt {prompt} pt tall (the dialog before it: {tall} pt)");
}
#[test]
fn dialogs_do_not_stretch_to_the_screen() {
// A dialog is as tall as its content: the same on a short and a tall screen.
let openers: &[(&str, serde_json::Value)] = &[
("ui.newSwatch", json!({})),
("ui.newColorGroup", json!({})),
("ui.swatchOptions", json!({"name": "White"})),
("ui.colorPicker", json!({})),
("ui.graphicStyleOptions", json!({})),
("ui.newArtboard", json!({})),
("ui.brushOptions", json!({"name": "3 pt. Round"})),
("ui.brushOptions", json!({"name": "Bristle Round"})),
("ui.colorGuideOptions", json!({})),
("ui.colorBalanceDialog", json!({})),
("ui.saturateDialog", json!({})),
("ui.saveSwatchLibrary", json!({})),
("ui.flattenTransparencyDialog", json!({})),
("ui.expandDialog", json!({})),
("ui.spotColors", json!({})),
("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2}})),
("effect.dialog", json!({"effect": "distort.roughen"})),
("ui.recolorDialog", json!({})),
];
let height = |id: &str, p: &serde_json::Value, screen_h: f32| -> Option<(String, f32)> {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
app.run(id, p.clone()).ok()?;
let kind = app.ui.dialog.as_ref()?.kind.clone();
spec(&kind).window.is_none().then_some(())?;
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, screen_h));
for _ in 0..4 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| show(&mut app, ui.ctx())).textures_delta.clear();
}
let h = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str()))))?.height();
Some((kind, h))
};
let mut checked = 0;
for (id, p) in openers {
let (Some((kind, short)), Some((_, tall))) = (height(id, p, 1000.0), height(id, p, 2000.0)) else { continue };
assert!((short - tall).abs() < 1.0, "{kind} is {short} pt tall on a 1000 pt screen but {tall} pt on a 2000 pt one");
checked += 1;
}
assert!(checked >= 10, "only {checked} dialogs drew in the shared frame");
}
/// The dialog window of `app`'s open dialog after a few frames in a `w` × 900 window.
fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 900.0));
let kind = app.ui.dialog.as_ref().unwrap().kind.clone();
for _ in 0..3 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
}
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str())))).unwrap()
}
#[test]
fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
let mut app = app();
// Clean Up: three check boxes in the generic parameter dialog.
let params = json!({"emptyTextPaths": true, "strayPoints": true, "unpaintedObjects": true});
app.run("ui.paramDialog", json!({"command": "object.path.cleanUp", "label": "Clean Up", "params": params})).unwrap();
let wide = dialog_rect(&mut app, 1600.0);
assert!(wide.width() < 500.0, "Clean Up is {:.0} wide in a 1600-point window", wide.width());
// The same dialog in a narrow window still fits it.
let narrow = dialog_rect(&mut app, 360.0);
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
/// Object › Path › Split Into Grid opens on a grid the size of the selection, shows its rows and
/// columns side by side (number, size, gutter, total), previews on the canvas and, on OK, splits
/// the path and adds the guides as one undo step.
#[test]
fn split_into_grid_opens_at_the_selections_size_and_previews() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 112, "height": 62})).unwrap();
app.run("select.all", json!({})).unwrap();
let undo = app.session.doc().unwrap().history.undo.len();
crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("totalWidth", 0.0), d.f64("totalHeight", 0.0), d.bool("addGuides")), (112.0, 62.0, false));
d.fields.extend([
("columns".to_string(), json!(3)),
("columnGutter".to_string(), json!(8)),
("addGuides".into(), json!(true)),
("preview".into(), json!(true)),
]);
frame(&mut app, Default::default());
// The preview: 2 × 3 cells and the guides along their edges, not yet an undo step.
assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10));
assert!(app.session.in_interaction());
confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1));
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
// Cancel rolls a preview back.
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({}));
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(objects(&app), 4, "one grid over the six cells");
cancel(&mut app);
assert_eq!(objects(&app), 6);
}
#[test]
fn menu_parameter_dialogs_are_compact() {
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
// Simplify opens its slider; More Options opens the dialog.
if id == "object.path.simplify" {
app.run("ui.simplifyMoreOptions", json!({})).unwrap();
}
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
let wide = dialog_rect(&mut app, 1600.0);
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
}
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]
fn mixed_lists_translate_only_their_built_in_entries() {
use crate::i18n::{Lang, tr};
let zh = Lang::from_code("zh-hant").unwrap();
for key in ["None", "Black", "Regular", "Default"] {
assert_ne!(tr(zh, key), key, "{key} must be a catalog key for this test");
}
let names = ["None", "Black", "Regular", "Default"];
assert_eq!(shown_names(zh, &names, |k| k == 0 || k == 3), [tr(zh, "None"), "Black", "Regular", tr(zh, "Default")]);
assert_eq!(shown_names(zh, &names, |_| false), names);
assert_eq!(shown_names(Lang::EN, &names, |_| true), names);
}
/// Confirmations and plug-in dialogs are headed by the text they are given (callers translate
/// their own templates; a plug-in's name is its own).
#[test]
fn given_headings_are_shown_as_they_are() {
let d = Dialog::new(confirm::KIND, json!({"message": "Delete “Black”?", "detail": "Regular"}));
assert_eq!((spec(&d.kind).heading)(&d), "Delete “Black”?");
let d = Dialog::new(plugin::KIND, json!({"__label": "Black", "__plugin": "x"}));
assert_eq!((spec(&d.kind).heading)(&d), "Black");
}
/// A plug-in dialog with one whole-number field `seed` in `min..=max`, driven by egui events.
struct PluginIntField {
ctx: egui::Context,
d: Dialog,
m: vectorcraft_plugins::Manifest,
pos: egui::Pos2,
}
impl PluginIntField {
/// The dialog for a `seed` in `min..=max` showing `start`, drawn once to find the number,
/// where drags start (`pos`).
fn new(min: i64, max: i64, start: i64) -> Self {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let m = vectorcraft_plugins::Manifest {
id: "test.seed".into(),
name: "Seed".into(),
version: String::new(),
kind: vectorcraft_plugins::Kind::Filter,
description: String::new(),
author: String::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
budget: Default::default(),
};
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO };
fn number_rect(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
match shape {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.visual_bounding_rect()),
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| number_rect(s, text)),
_ => None,
}
}
let out = f.run(vec![]);
f.pos = out.shapes.iter().find_map(|s| number_rect(&s.shape, &start.to_string())).unwrap().center();
f
}
fn run(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
let (d, m) = (&mut self.d, &self.m);
let mut out = self.ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
form::schema_rows(ui, d, m);
});
out.textures_delta.clear();
out
}
/// Clicks the field, sends `events`, presses Enter and returns the field's value.
fn commit(&mut self, events: Vec<egui::Event>) -> serde_json::Value {
use egui::{Event, Key, Modifiers, PointerButton};
let (pos, button) = (self.pos, PointerButton::Primary);
self.run(vec![Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::default() }]);
self.run(vec![Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::default() }]);
self.run(events);
self.run(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() }]);
self.d.fields["seed"].clone()
}
/// Replaces the field's text with `text` and commits it.
fn type_text(&mut self, text: &str) -> serde_json::Value {
let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
self.commit(vec![
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: all },
egui::Event::Text(text.into()),
])
}
}
#[test]
fn plugin_integer_field_preserves_precision_and_clamps_exactly() {
let (min, max) = (-9007199254740995_i64, 9007199254740995_i64);
let mut f = PluginIntField::new(min, max, 0);
for (text, expected) in [
("9007199254740993", 9007199254740993_i64),
("9007199254740996", max),
("-9007199254740996", min),
("invalid", min),
("1.5", min),
("9223372036854775808", min),
] {
assert_eq!(f.type_text(text), json!(expected), "{text}");
}
}
#[test]
fn plugin_integer_field_with_small_bounds_keeps_numeric_editing() {
let mut f = PluginIntField::new(1, 16, 3);
let up = egui::Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
assert_eq!(f.commit(vec![up]), json!(4));
for (text, expected) in [("7", 7), ("2 + 3", 5), ("17", 16)] {
assert_eq!(f.type_text(text), json!(expected), "{text}");
}
}
#[test]
fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
// Object › Transform › Rotate…: type 90 over the angle, press Enter.
crate::menus::invoke(&mut app, "object.rotate", json!({}));
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("90".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let b = bounds(&app);
assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}");
// Move…: its first field is a distance (with a unit): typing replaces it as well.
let before = bounds(&app);
crate::menus::invoke(&mut app, "object.move", json!({}));
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("15".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let after = bounds(&app);
assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}");
// Transform Each, then the Transform effect: Scale › Horizontal, a plain number, comes first.
let before = bounds(&app);
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("50".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let after = bounds(&app);
assert!((after.width() - before.width() / 2.0).abs() < 1e-6 && (after.height() - before.height()).abs() < 1e-6, "{before:?} → {after:?}");
app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("50".into())]);
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none());
let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0)));
}
/// In a dialog, Shift+Enter in a field applies its value and confirms the dialog, as Enter does:
/// the Move dialog moves the art, and the New Document dialog creates a document as wide as typed.
/// Panel fields keep the focus on Shift+Enter (`tests_numfields`).
#[test]
fn shift_enter_in_a_dialog_field_applies_it_and_confirms_the_dialog() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0)));
let mut time = 0.0;
// Whole app frames: the panels, then the dialog.
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
};
let shift_enter =
|| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT };
let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
// Move…: type 15 over its first field and press Shift+Enter.
let before = bounds(&app);
crate::menus::invoke(&mut app, "object.move", json!({}));
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("15".into())]);
frame(&mut app, vec![shift_enter()]);
assert!(app.ui.dialog.is_none(), "Shift+Enter confirms the Move dialog");
let after = bounds(&app);
assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}");
// New Document (the recent 200 × 200 pt size): click into Width, type 300 and press Shift+Enter.
app.run("file.newDialog", json!({})).unwrap();
frame(&mut app, vec![]);
let out = frame(&mut app, vec![]);
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
// The Width field comes right after its caption.
let width = texts.iter().position(|(t, _)| t == "Width").and_then(|i| texts.get(i + 1));
assert_eq!(width.map(|(t, _)| t.as_str()), Some("200 pt"), "{texts:?}");
let at = width.map(|(_, p)| *p).unwrap();
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at), press(true)]);
frame(&mut app, vec![press(false)]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("300".into())]);
frame(&mut app, vec![shift_enter()]);
assert!(app.ui.dialog.is_none(), "Shift+Enter creates the document");
assert_eq!(app.session.active().unwrap().doc.artboards[0].rect.width(), 300.0);
}
/// #1367: a dialog opened by a click (its menu item) is drawn in the frame of that click, where egui
/// takes the focus from a field the click missed: its first field still has the focus, each time it
/// opens.
#[test]
fn a_dialog_opened_by_a_click_focuses_its_first_field_each_time_it_opens() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
// A click away from the dialog, as on a menu item.
let click = || {
let (pos, button, modifiers) = (egui::pos2(5.0, 5.0), egui::PointerButton::Primary, egui::Modifiers::NONE);
let press = |pressed| egui::Event::PointerButton { pos, button, pressed, modifiers };
vec![egui::Event::PointerMoved(pos), press(true), press(false)]
};
type Open = fn(&mut VectorcraftApp);
let opens: [(&str, Open); 2] = [
("Convert to Shape", |app| drop(app.run("effect.dialog", json!({"effect": "convertToShape.rectangle"})))),
("Rasterize", |app| crate::menus::invoke(app, "object.rasterize", json!({}))),
];
for (name, open) in opens {
for turn in 1..=3 {
open(&mut app);
assert!(app.ui.dialog.is_some(), "{name}");
frame(&mut app, click());
frame(&mut app, vec![]);
frame(&mut app, vec![]);
assert!(ctx.memory(|m| m.focused()).is_some(), "{name}, turn {turn}");
cancel(&mut app);
// A frame without the dialog, as between two uses of it.
frame(&mut app, vec![]);
assert!(ctx.memory(|m| m.focused()).is_none(), "{name}, turn {turn}");
}
}
}
/// A greyed-out field can't hold the keyboard focus, so it doesn't take a just-opened dialog's
/// either: the focus waits for the first enabled field. Blend Options on Smooth Color shows its
/// steps greyed out; choosing Specified Steps enables the field and it takes the focus (type a
/// count, press Enter).
#[test]
fn a_dialogs_focus_skips_a_greyed_out_first_field() {
let mut app = app();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
app.run("ui.blendOptions", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("smooth"));
for _ in 0..3 {
frame(&mut app, vec![]);
}
assert!(ctx.memory(|m| m.focused()).is_none(), "nothing to type in yet");
app.ui.dialog.as_mut().unwrap().fields.insert("spacing".into(), json!("steps"));
frame(&mut app, vec![]);
assert!(ctx.memory(|m| m.focused()).is_some(), "the steps field takes the focus once it is enabled");
frame(&mut app, vec![egui::Event::Text("7".into())]);
frame(&mut app, vec![enter]);
assert!(app.ui.dialog.is_none());
let info = app.run("object.blend.info", json!({})).unwrap();
assert_eq!((info["spacing"].clone(), info["steps"].clone()), (json!("steps"), json!(7)), "{info}");
}
/// #1692: a value typed into the field before the # field of the Color Picker or Swatch Options
/// stays when Tab passes through the # field. The # field takes the focus in the frame the value is
/// committed, still showing the color from before it, and commits nothing when Tab moves on. A hex
/// typed into the # field is still committed by Tab.
#[test]
fn tab_through_the_hex_field_keeps_the_value_typed_before_it() {
let mut app = app();
app.run("swatch.new", json!({"name": "Sky", "color": "#3366cc"})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
// One frame of the dialog layer: every text drawn, with its center.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
texts
};
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let (tab, back) = (key(egui::Key::Tab, egui::Modifiers::NONE), key(egui::Key::Tab, egui::Modifiers::SHIFT));
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let color = |app: &VectorcraftApp| app.ui.dialog.as_ref().unwrap().fields["color"].clone();
type Open = fn(&mut VectorcraftApp);
let opens: [(&str, Open, &str); 2] = [
("Color Picker", |app| drop(app.run("ui.colorPicker", json!({"color": "#ffffff"}))), "FFFFFF"),
("Swatch Options", |app| drop(app.run("ui.swatchOptions", json!({"name": "Sky"}))), "3366CC"),
];
for (name, open, hex) in opens {
open(&mut app);
let before = color(&app);
for _ in 0..3 {
frame(&mut app, vec![]);
}
let at = frame(&mut app, vec![]).into_iter().find(|(t, _)| t == hex).unwrap_or_else(|| panic!("{name}: no # field shows {hex}")).1;
// Click into the # field, then Shift+Tab to the field before it and type 40 over its value.
frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]);
frame(&mut app, vec![button(at, false)]);
frame(&mut app, vec![back.clone()]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("40".into())]);
// Tab commits it and gives the # field the focus; the next Tab moves on.
frame(&mut app, vec![tab.clone()]);
let edited = color(&app);
assert_ne!(edited, before, "{name}: the field before # took 40");
for events in [vec![], vec![tab.clone()], vec![]] {
frame(&mut app, events);
}
assert_eq!(color(&app), edited, "{name}: passing through # put the color from before the edit back");
// Click into the # field, select its text, type a hex over it and press Tab.
frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]);
frame(&mut app, vec![button(at, false)]);
frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
frame(&mut app, vec![egui::Event::Text("00FF00".into())]);
frame(&mut app, vec![tab.clone()]);
assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: the # field took 00FF00");
cancel(&mut app);
frame(&mut app, vec![]);
}
}
/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed
/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid.
#[test]
fn object_mosaic_makes_the_grid_typed_in_its_dialog() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
app.run("object.rasterize", json!({"ppi": 72})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Create Object Mosaic…").expect("menu item");
let (cmd, params) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
crate::menus::invoke(&mut app, &cmd, params);
// Its first field, Columns, is focused: type 3 over it and press Enter.
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("3".into())]);
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(&mut app, vec![enter]);
assert!(app.ui.dialog.is_none());
let mut tiles = None;
app.session.doc().unwrap().doc.walk(|n| {
if n.name.as_deref() == Some("Object Mosaic") {
tiles = n.children().map(|c| c.len());
}
});
assert_eq!(tiles, Some(3 * 10));
}
/// #793: Artboard Options lists its fields as Name, Width, Height, not by key.
#[test]
fn artboard_options_lists_name_then_width_and_height() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap();
open(&mut app, "artboardOptions", json!({"index": 0, "name": "Artboard 1", "width": 612, "height": 792, "x": 0, "y": 0}));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} in {text}"));
assert!(at("Name") < at("Width") && at("Width") < at("Height"), "{text}");
}
#[test]
fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_again() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
for s in [
"Transform",
"Scale",
"Move",
"Rotate",
"Horizontal:",
"Vertical:",
"Angle:",
"Copies:",
"Reflect X",
"Reflect Y",
"Reference Point",
"Random",
"Transform Objects",
"Transform Patterns",
"Scale Strokes & Effects",
"Preview",
] {
assert!(text.lines().any(|l| l == s), "{s} in {text}");
}
assert!(app.session.in_interaction(), "the preview runs");
// The options start at the catalog defaults (#885).
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.bool("objects"), d.bool("patterns"), d.bool("strokes")), (true, false, false));
let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone();
// The reference point and Random update the preview.
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in [("moveH", json!(30)), ("copies", json!(3)), ("reference", json!(8))] {
d.fields.insert(k.into(), v);
}
frame(&mut app, Default::default());
assert_eq!((effect(&app).params["reference"].clone(), effect(&app).params["copies"].clone()), (json!(8), json!(3)));
app.ui.dialog.as_mut().unwrap().fields.insert("random".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(effect(&app).params["random"], json!(true));
// So does Scale Strokes & Effects.
app.ui.dialog.as_mut().unwrap().fields.insert("strokes".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(effect(&app).params["strokes"], json!(true));
confirm(&mut app).unwrap();
assert!(!app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.len(), 1);
// Editing it from the Appearance panel shows the same values.
app.run("effect.dialog", json!({"effect": "distort.transform", "index": 0})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("reference", 4.0), d.f64("copies", 0.0), d.f64("moveH", 0.0), d.bool("random")), (8.0, 3.0, 30.0, true));
assert_eq!((d.bool("objects"), d.bool("strokes")), (true, true));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(text.lines().any(|l| l == "Reference Point"), "{text}");
}
/// Drop Shadow's dialog has Color and Darkness as radio buttons, each with its field; a click on
/// Darkness previews the shadow with it and OK applies it.
#[test]
fn drop_shadow_chooses_color_or_darkness() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("effect.dialog", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
let out = run(&mut app, vec![]);
let text = crate::tests_labels::shapes_text(&out);
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for want in ["Mode:", "Opacity:", "Blur:", "Color", "Darkness"] {
assert!(lines.contains(&want), "no {want:?} in {lines:?}");
}
assert!(!lines.contains(&"Shade:"), "{lines:?}");
fn find(shape: &egui::Shape) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(t) if t.galley.text().trim() == "Darkness" => Some(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().find_map(find),
_ => None,
}
}
let at = out.shapes.iter().find_map(|s| find(&s.shape)).unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)], vec![], vec![]] {
run(&mut app, events);
}
let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone();
assert!(app.session.in_interaction(), "the preview runs");
assert_eq!(effect(&app).params["shade"], json!("darkness"));
confirm(&mut app).unwrap();
let params = effect(&app).params;
assert_eq!((params["shade"].clone(), params["darkness"].clone()), (json!("darkness"), json!(100.0)));
}
#[test]
fn artboard_options_position_moves_art_only_with_the_tool_option_on() {
for move_art in [false, true] {
let mut app = app();
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));
crate::dialogs::artboard_options::open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("x".into(), json!(50));
// Draw the dialog once and confirm through its registry, as the OK button does.
frame(&mut app, Default::default());
let before = app.session.doc().unwrap().doc.clone();
super::confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect.x0, 50.0);
assert_eq!(
app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0,
if move_art { 60.0 } else { 10.0 }
);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(*app.session.doc().unwrap().doc, *before);
}
}
/// Opened while another tool is in use, as the Artboards panel opens it (#1876), Artboard Options
/// moves the art with a new X only when the Artboard tool's Move Artwork with Artboard option is
/// on.
#[test]
fn artboard_options_position_follows_the_tool_option_with_another_tool() {
for move_art in [false, true] {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("tool.setOption", json!({"tool": "artboard", "key": "moveArt", "value": move_art})).unwrap();
app.run("tool.options", json!({"tool": "artboard"})).unwrap();
assert_ne!(app.session.tool_id(), "artboard");
app.ui.dialog.as_mut().unwrap().fields.insert("x".into(), json!(50));
frame(&mut app, Default::default());
super::confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect.x0, 50.0);
assert_eq!(
app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0,
if move_art { 60.0 } else { 10.0 }
);
}
}
/// Artboard Options shows the artboard's background color on its Background Color: row (None,
/// White, Black, or Custom for another color) and not as a text field. OK keeps the color while it
/// applies another field, and a null `color` clears it.
#[test]
fn artboard_options_shows_keeps_and_clears_the_background_color() {
let mut app = app();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[0].color.map(|c| c.to_hex());
let lines = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx()));
text.lines().map(str::trim).map(str::to_string).collect::<Vec<_>>()
};
crate::dialogs::artboard_options::open(&mut app).unwrap();
let shown = lines(&mut app);
assert!(shown.iter().any(|l| l == "Background Color:") && shown.iter().any(|l| l == "None"), "{shown:?}");
app.ui.dialog = None;
app.run("artboard.setProps", json!({"index": 0, "color": "#336699"})).unwrap();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let shown = lines(&mut app);
assert!(shown.iter().any(|l| l == "Custom") && !shown.iter().any(|l| l == "Color:" || l.starts_with('[')), "{shown:?}");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Card"));
super::confirm(&mut app).unwrap();
assert_eq!((app.session.doc().unwrap().doc.artboards[0].name.as_str(), color(&app).as_deref()), ("Card", Some("#336699")));
crate::dialogs::artboard_options::open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(null));
super::confirm(&mut app).unwrap();
assert_eq!(color(&app), None);
}
/// Artboard Options for a new artboard (`ui.newArtboard`) gives the new artboard the background
/// color picked on its Background Color: row, and none when nothing was picked.
#[test]
fn new_artboard_options_give_the_new_artboard_its_background_color() {
let mut app = app();
for (picked, want) in [(Some(json!("#336699")), Some("#336699")), (None, None)] {
app.run("ui.newArtboard", json!({})).unwrap();
if let Some(c) = picked {
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), c);
}
super::confirm(&mut app).unwrap();
let ab = app.session.doc().unwrap().doc.artboards.last().cloned().unwrap();
assert_eq!(ab.color.map(|c| c.to_hex()).as_deref(), want);
}
}
/// Artboard Options shows Show Center Mark and Show Cross Hairs, ticked as the artboard has them
/// whether it opens from the Artboards panel or from a double-click with the Artboard tool. OK
/// sets them in one undo step, and a new artboard's Artboard Options gives it the ones ticked.
#[test]
fn artboard_options_show_center_mark_and_cross_hairs() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
let marks = |app: &VectorcraftApp, i: usize| {
let a = &app.session.doc().unwrap().doc.artboards[i];
(a.show_center_mark, a.show_cross_hairs)
};
crate::dialogs::artboard_options::open(&mut app).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(text.contains("Show Center Mark") && text.contains("Show Cross Hairs"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert!(!d.bool("showCenterMark") && !d.bool("showCrossHairs"));
d.fields.insert("showCenterMark".into(), json!(true));
let undo = app.session.doc().unwrap().history.undo.len();
super::confirm(&mut app).unwrap();
assert_eq!(marks(&app, 0), (true, false));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
// Opened again, it shows them; unticking one clears it.
crate::dialogs::artboard_options::open(&mut app).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
assert!(d.bool("showCenterMark"));
d.fields.insert("showCenterMark".into(), json!(false));
d.fields.insert("showCrossHairs".into(), json!(true));
super::confirm(&mut app).unwrap();
assert_eq!(marks(&app, 0), (false, true));
// A double-click with the Artboard tool on the artboard's empty space.
app.select_tool("artboard");
let view = app.view_info();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 100.0, 100.0), view);
}
let d = app.ui.dialog.clone().expect("Artboard Options opened");
assert_eq!((d.kind.as_str(), d.bool("showCenterMark"), d.bool("showCrossHairs")), ("artboardOptions", false, true));
cancel(&mut app);
// A new artboard takes the ones ticked, and none when none is.
for (ticked, want) in [(Some("showCenterMark"), (true, false)), (None, (false, false))] {
app.run("ui.newArtboard", json!({})).unwrap();
if let Some(k) = ticked {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), json!(true));
}
super::confirm(&mut app).unwrap();
let last = app.session.doc().unwrap().doc.artboards.len() - 1;
assert_eq!(marks(&app, last), want);
}
}
/// #1482: with Artboards 1 and 3 selected with the Artboard tool, a new Width in Artboard Options
/// goes to both, each keeping its top-left corner and its height, in one undo step. The new name
/// goes only to the artboard the dialog shows, and Artboard 2 keeps its size. A name alone resizes
/// nothing.
#[test]
fn artboard_options_width_resizes_every_selected_artboard() {
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");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let boards = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.clone();
let before = boards(&app);
let undo = app.session.doc().unwrap().history.undo.len();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
fields.insert("width".into(), json!("250 pt"));
fields.insert("name".into(), json!("Cover"));
frame(&mut app, Default::default());
super::confirm(&mut app).unwrap();
let mut want = before.clone();
want[0].name = "Cover".into();
for i in [0, 2] {
want[i].rect.x1 = want[i].rect.x0 + 250.0;
}
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);
crate::dialogs::artboard_options::open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cover"));
frame(&mut app, Default::default());
super::confirm(&mut app).unwrap();
want = before.clone();
want[0].name = "Cover".into();
assert_eq!(boards(&app), want, "a name alone");
}
/// #1482 with #1876: a Width typed in Artboard Options resizes each selected artboard about the
/// reference point the form uses, the center by default, as the Transform panel's W does.
#[test]
fn artboard_options_width_resizes_every_selected_artboard_about_the_reference_point() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300, "artboards": 2})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 1]));
let before: Vec<vectorcraft_geom::Rect> = app.session.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let mut f = Frames::new();
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.
f.press(&mut app, height, true);
f.press(&mut app, height, false);
f.key(&mut app, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_none(), "Enter applies the dialog");
let after: Vec<vectorcraft_geom::Rect> = app.session.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let want: Vec<vectorcraft_geom::Rect> = before.iter().map(|r| vectorcraft_geom::Rect::new(r.x0 - 50.0, r.y0, r.x1 + 50.0, r.y1)).collect();
assert_eq!(after, want);
}
/// 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]
fn zig_zag_has_sliders_and_radio_buttons() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.zigZag"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for want in ["Size:", "Relative", "Absolute", "Ridges per segment:", "Points", "Smooth", "Corner"] {
assert!(lines.contains(&want), "no {want:?} in {lines:?}");
}
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
}
/// Screen readers get the number and text of the field beside each dialog slider, in the field's
/// unit, with the rail's range (extended to a number past the rail's end): Transform Each's Scale
/// and Move, Zig Zag's Size and Ridges, a form slider (Vector Halftone's Frequency), the Pencil's
/// Fidelity, the Color Picker's channel slider, the Perspective Grid's Opacity, Revolve's angle and
/// Offset, Extrude & Bevel's Rotation X and Y, Swatch Options' components and the flattener's Raster/Vector Balance.
#[test]
fn dialog_sliders_report_the_numbers_their_fields_show() {
use crate::widgets::accessibility_tests::{slider_value, tree};
use egui::accesskit::Role::Slider;
fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) {
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v);
}
let v = |n: f64, (lo, hi): (f64, f64), text: &str| (Some(n), Some(lo), Some(hi), Some(text.to_string()));
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
set(&mut app, "scaleH", json!(250));
set(&mut app, "moveH", json!(-36));
let shown = tree(|ui| show(&mut app, ui.ctx()));
let horizontal: Vec<_> = shown
.iter()
.filter(|n| n.role() == Slider && n.label() == Some("Horizontal:"))
.map(|n| (n.numeric_value(), n.min_numeric_value(), n.max_numeric_value(), n.value().map(str::to_string)))
.collect();
assert_eq!(
horizontal,
[(Some(250.0), Some(0.0), Some(250.0), Some("250%".into())), (Some(-36.0), Some(-1000.0), Some(1000.0), Some("-36 pt".into()))]
);
app.ui.dialog = None;
app.run("effect.dialog", json!({"effect": "distort.zigZag"})).unwrap();
set(&mut app, "relative", json!(true));
set(&mut app, "size", json!(30));
set(&mut app, "ridges", json!(6));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Size:"), v(30.0, (0.0, 100.0), "30%"));
assert_eq!(slider_value(&shown, "Ridges per segment:"), (Some(6.0), Some(0.0), Some(100.0), None));
set(&mut app, "relative", json!(false));
set(&mut app, "size", json!(12));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Size:"), v(12.0, (0.0, 100.0), "12 pt"), "Absolute: a distance");
open(&mut app, halftone::KIND, halftone::fields());
set(&mut app, "frequency", json!(60));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Frequency"), v(60.0, (1.0, 150.0), "60 lpi"));
app.ui.dialog = None;
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
set(&mut app, "fidelity", json!(2.5));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Fidelity:"), v(2.5, (0.5, 20.0), "2.5pt"));
app.ui.dialog = None;
app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap();
app.run("ui.colorPicker", json!({})).unwrap();
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "H"), v(30.0, (0.0, 360.0), "30°"));
set(&mut app, "channel", json!("green"));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "G"), v(128.0, (0.0, 255.0), "128"));
app.ui.dialog = None;
app.run("ui.perspectiveGridDialog", json!({})).unwrap();
set(&mut app, "opacity", json!(40));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Opacity:"), v(40.0, (0.0, 100.0), "40%"));
app.ui.dialog = None;
app.run("path.create", json!({"d": "M150 40 L180 60 L180 140 L150 160"})).unwrap();
app.run("effect.dialog", json!({"effect": vectorcraft_effects::REVOLVE, "item": null})).unwrap();
set(&mut app, "angle", json!(270));
set(&mut app, "offset", json!(600));
let shown = tree(|ui| show(&mut app, ui.ctx()));
// The 3D dialogs' fields are DragValues, which keep a trailing zero at one place.
assert_eq!(slider_value(&shown, "Revolve angle"), v(270.0, (0.0, 360.0), "270.0°"));
assert_eq!(slider_value(&shown, "Offset"), v(600.0, (0.0, 600.0), "600.0 pt"), "past the rail's 500 pt");
app.ui.dialog = None;
app.run("effect.dialog", json!({"effect": vectorcraft_effects::EXTRUDE, "item": null})).unwrap();
set(&mut app, "__tab", json!("rotation"));
set(&mut app, "rotationX", json!(30));
set(&mut app, "rotationY", json!(12.25));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Rotation X"), v(30.0, (-180.0, 180.0), "30.0°"), "at most 2 places");
assert_eq!(slider_value(&shown, "Rotation Y"), v(12.25, (-180.0, 180.0), "12.25°"));
app.ui.dialog = None;
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true})).unwrap();
app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap();
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "M"), v(80.0, (0.0, 100.0), "80%"));
let mut o = vectorcraft_engine::cmd::FlattenOptions { balance: 30.0, ..Default::default() };
let shown = tree(|ui| {
flatten::options_editor(ui, "flatten-test", &mut o, true);
});
assert_eq!(slider_value(&shown, "Raster/Vector Balance:"), v(30.0, (0.0, 100.0), "30"));
}
/// Dragging Pattern Brush Options' Scale slider past its right end sets Scale to 10000%, and
/// dragging Art Brush Options' Width slider past its left end sets Width to 1%; OK gives each
/// brush its new value.
#[test]
fn brush_options_sliders_set_pattern_scale_and_art_width() {
use egui::accesskit::Role::Slider;
let mut app = app();
for (brush, label, key, by, want) in [("Diamonds", "Scale:", "scale", 400.0, 10000.0), ("Arrow", "Width:", "width", -400.0, 1.0)] {
app.run("ui.brushOptions", json!({ "name": brush })).unwrap();
let mut f = Frames::new();
let at = f.control(&mut app, label, Slider);
f.drag(&mut app, at, egui::vec2(by, 0.0));
assert_eq!(app.ui.dialog.as_ref().expect("Brush Options is open").f64(key, 0.0), want, "{brush}");
confirm(&mut app).unwrap();
assert_eq!(app.run("brush.get", json!({ "name": brush })).unwrap()[key], json!(want), "{brush}");
}
}
/// Frames of the dialog layer on one context (so a drag and the keyboard focus last from frame to
/// frame), with the screen-reader tree turned on.
struct Frames {
ctx: egui::Context,
time: f64,
}
impl Frames {
fn new() -> Self {
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
Self { ctx, time: 0.0 }
}
fn run(&mut self, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
self.time += 0.1;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0));
let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() };
let mut out = self.ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
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() == 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)
}
fn press(&mut self, app: &mut VectorcraftApp, at: egui::Pos2, pressed: bool) {
let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
self.run(app, vec![egui::Event::PointerMoved(at), button]);
}
fn drag(&mut self, app: &mut VectorcraftApp, from: egui::Pos2, by: egui::Vec2) {
self.press(app, from, true);
for k in 1..=4 {
self.run(app, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]);
}
self.press(app, from + by, false);
}
fn key(&mut self, app: &mut VectorcraftApp, key: egui::Key, modifiers: egui::Modifiers) {
let event = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
self.run(app, vec![egui::Event::ModifiersChanged(modifiers), event]);
self.run(app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
}
/// The corners in the open dialog, and those of the first object's first effect.
fn free_distort_corners(app: &VectorcraftApp) -> (serde_json::Value, serde_json::Value) {
let s = app.session.active().unwrap();
let node = s.doc.node(s.selection.objects[0]).unwrap();
(app.ui.dialog.as_ref().unwrap().fields["corners"].clone(), node.appearance.effects[0].params["corners"].clone())
}
/// Free Distort's dialog moves the corner whose handle is dragged and leaves the other three; the
/// preview and OK apply them, and the arrow keys move the handle that a click focused.
#[test]
fn free_distort_drags_one_corner_and_previews_it() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "distort.freeDistort"})).unwrap();
let mut f = Frames::new();
// The box keeps the object's 2:1 proportions: 180 by 90 screen points.
let top_right = f.button(&mut app, "Top Right");
f.drag(&mut app, top_right, egui::vec2(27.0, 9.0));
let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(free_distort_corners(&app), (want.clone(), want.clone()), "the dragged corner alone moves, and the preview follows");
let bottom_left = f.button(&mut app, "Bottom Left");
f.press(&mut app, bottom_left, true);
f.press(&mut app, bottom_left, false);
f.key(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE);
f.key(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT);
let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [-0.01, 1.1]]);
assert_eq!(free_distort_corners(&app), (want.clone(), want.clone()));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
let s = app.session.active().unwrap();
assert_eq!(s.doc.node(s.selection.objects[0]).unwrap().appearance.effects[0].params["corners"], want);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before, "one undo step");
}
/// An arrow key moves a corner that lies outside the dialog's area by one step, and Reset puts the
/// corners of an applied Free Distort back on the box; OK keeps that.
#[test]
fn free_distort_reset_puts_the_corners_back() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let moved = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 3.0], [0.0, 1.0]]);
app.run("effect.apply", json!({"effect": "distort.freeDistort", "params": {"corners": moved}})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.freeDistort", "index": 0})).unwrap();
let mut f = Frames::new();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], moved);
let bottom_right = f.button(&mut app, "Bottom Right");
f.press(&mut app, bottom_right, true);
f.press(&mut app, bottom_right, false);
f.key(&mut app, egui::Key::ArrowUp, egui::Modifiers::NONE);
let stepped = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 2.99], [0.0, 1.0]]);
assert_eq!(free_distort_corners(&app), (stepped.clone(), stepped));
let reset = f.button(&mut app, "Reset");
f.press(&mut app, reset, true);
f.press(&mut app, reset, false);
f.run(&mut app, vec![]);
let rest = json!(super::free_distort::IDENTITY);
assert_eq!(free_distort_corners(&app), (rest.clone(), rest.clone()));
confirm(&mut app).unwrap();
let s = app.session.active().unwrap();
let effects = &s.doc.node(s.selection.objects[0]).unwrap().appearance.effects;
assert_eq!((effects.len(), effects[0].params["corners"].clone()), (1, rest));
}
/// The slider that screen readers name `label` in `out`'s tree: its node id, number and text.
fn slider_node(out: &egui::FullOutput, label: &str) -> (egui::accesskit::NodeId, Option<f64>, Option<String>) {
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let (id, node) = nodes
.iter()
.find(|(_, n)| n.role() == egui::accesskit::Role::Slider && n.label() == Some(label))
.unwrap_or_else(|| panic!("no slider named {label:?}"));
(*id, node.numeric_value(), node.value().map(str::to_string))
}
/// A form slider that a click gave the keyboard focus (Vector Halftone's Frequency, 1 to 150 lpi)
/// steps the number of the field beside it with the arrow keys: one with → and ↑, back with ← and
/// ↓, ten with Shift, and ten with Page Up and Page Down. Home and End take it to the ends of its
/// range, a step stops there, and screen readers read each new number.
#[test]
fn a_focused_dialog_slider_steps_with_the_arrow_keys() {
use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, PageDown, PageUp};
use egui::Modifiers as M;
let mut app = app();
open(&mut app, halftone::KIND, halftone::fields());
let mut f = Frames::new();
let at = f.control(&mut app, "Frequency", egui::accesskit::Role::Slider);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
let frequency = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Vector Halftone is open").f64("frequency", 0.0);
let start = frequency(&app);
assert!((2.0..=139.0).contains(&start), "the click set {start}");
let mut seen = vec![];
for (key, mods, want) in [
(ArrowRight, M::NONE, start + 1.0),
(ArrowUp, M::NONE, start + 2.0),
(ArrowLeft, M::NONE, start + 1.0),
(ArrowDown, M::NONE, start),
(ArrowRight, M::SHIFT, start + 10.0),
(ArrowLeft, M::SHIFT, start),
(Home, M::NONE, 1.0),
(ArrowLeft, M::NONE, 1.0),
(PageUp, M::NONE, 11.0),
(End, M::NONE, 150.0),
(ArrowUp, M::SHIFT, 150.0),
(PageDown, M::NONE, 140.0),
(PageUp, M::NONE, 150.0),
] {
f.key(&mut app, key, mods);
seen.push((key, mods.shift, frequency(&app)));
assert_eq!(frequency(&app), want, "{seen:?}");
}
f.key(&mut app, ArrowLeft, M::NONE);
let out = f.run(&mut app, vec![]);
assert_eq!((frequency(&app), slider_node(&out, "Frequency").1), (149.0, Some(149.0)));
assert_eq!(slider_node(&out, "Frequency").2.as_deref(), Some("149 lpi"));
}
/// Tab leaves a focused dialog slider for its field and Shift+Tab comes back to it, whose arrow
/// keys then step the number again: the Perspective Grid's Opacity. OK gives the grid the new
/// opacity.
#[test]
fn tab_and_shift_tab_reach_a_dialog_slider_for_the_arrow_keys() {
use egui::Key::{ArrowRight, Tab};
use egui::Modifiers as M;
let mut app = app();
app.run("ui.perspectiveGridDialog", json!({})).unwrap();
let opacity = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Define Grid is open").f64("opacity", 0.0);
let mut f = Frames::new();
let at = f.control(&mut app, "Opacity:", egui::accesskit::Role::Slider);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
let start = opacity(&app);
f.key(&mut app, Tab, M::NONE);
f.key(&mut app, ArrowRight, M::NONE);
assert_eq!(opacity(&app), start, "the field has the focus, and → moves its caret");
f.key(&mut app, Tab, M::SHIFT);
f.key(&mut app, ArrowRight, M::NONE);
f.key(&mut app, ArrowRight, M::SHIFT);
assert_eq!(opacity(&app), start + 11.0);
confirm(&mut app).unwrap();
assert_eq!(app.run("perspective.grid.get", json!({})).unwrap()["define"]["opacity"].as_f64(), Some(start + 11.0));
}
/// Screen readers can step a dialog slider: the Pencil's Fidelity, a field with tenths, goes to
/// the next whole number on a decrement or an increment, as the slider's ← and → do.
#[test]
fn screen_readers_step_a_dialog_slider() {
use egui::accesskit::{Action, ActionRequest, TreeId};
let mut app = app();
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
app.ui.dialog.as_mut().expect("Pencil Tool Options is open").fields.insert("fidelity".into(), json!(2.5));
let fidelity = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Pencil Tool Options is open").f64("fidelity", 0.0);
let mut f = Frames::new();
f.run(&mut app, vec![]);
let out = f.run(&mut app, vec![]);
let (node, ..) = slider_node(&out, "Fidelity:");
let request = |action| egui::Event::AccessKitActionRequest(ActionRequest { action, target_node: node, target_tree: TreeId::ROOT, data: None });
f.run(&mut app, vec![request(Action::Decrement)]);
assert_eq!(fidelity(&app), 2.0, "from 2.5");
f.run(&mut app, vec![request(Action::Increment)]);
f.run(&mut app, vec![request(Action::Increment)]);
assert_eq!(fidelity(&app), 4.0);
}
/// Transform Each's sliders step the numbers of their fields: a Scale past the rail's 200% steps
/// down from 200% and End sets 200%, → at the end leaves it as it is, and a Move slider steps one
/// millimeter in a millimeter document.
#[test]
fn transform_each_sliders_step_the_numbers_their_fields_show() {
use egui::Key::{ArrowLeft, ArrowRight, End, Tab};
use egui::Modifiers as M;
let mut app = app();
app.run("document.setUnits", json!({"units": "millimeters"})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
let set =
|app: &mut VectorcraftApp, k: &str, v: serde_json::Value| app.ui.dialog.as_mut().expect("Transform Each is open").fields.insert(k.into(), v);
let get = |app: &VectorcraftApp, k: &str| app.ui.dialog.as_ref().expect("Transform Each is open").f64(k, f64::NAN);
let mut f = Frames::new();
let at = f.control(&mut app, "Horizontal:", egui::accesskit::Role::Slider);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
set(&mut app, "scaleH", json!(300));
f.key(&mut app, ArrowRight, M::NONE);
assert_eq!(get(&app, "scaleH"), 300.0, "→ at the rail's end");
f.key(&mut app, End, M::NONE);
assert_eq!(get(&app, "scaleH"), 200.0);
set(&mut app, "scaleH", json!(300));
f.key(&mut app, ArrowLeft, M::NONE);
assert_eq!(get(&app, "scaleH"), 199.0);
// Tab goes through the Scale fields and sliders to the Move slider.
set(&mut app, "moveH", json!(0));
for _ in 0..4 {
f.key(&mut app, Tab, M::NONE);
}
f.key(&mut app, ArrowRight, M::NONE);
assert!((get(&app, "moveH") - 72.0 / 25.4).abs() < 1e-9, "1 mm: {} pt", get(&app, "moveH"));
}
/// The sliders the flattener options and the 3D dialogs draw take the arrow keys as well: Flatten
/// Transparency's Raster/Vector Balance and 3D Revolve's angle each step by ten with Shift, and
/// down to the next whole number with ↓.
#[test]
fn the_flattener_and_3d_sliders_step_with_the_arrow_keys() {
use egui::Modifiers as M;
let mut app = app();
app.run("path.create", json!({"d": "M150 40 L180 60 L180 140 L150 160"})).unwrap();
for (open, label, key) in [("flatten", "Raster/Vector Balance:", "balance"), ("revolve", "Revolve angle", "angle")] {
if open == "flatten" {
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
} else {
app.run("effect.dialog", json!({"effect": vectorcraft_effects::REVOLVE, "item": null})).unwrap();
}
let value = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("the dialog is open").f64(key, -1.0);
let mut f = Frames::new();
let at = f.control(&mut app, label, egui::accesskit::Role::Slider);
f.press(&mut app, at, true);
f.press(&mut app, at, false);
let start = value(&app);
f.key(&mut app, egui::Key::ArrowRight, M::SHIFT);
assert_eq!(value(&app), start + 10.0, "{label}");
// A plain step goes to the next whole number below.
f.key(&mut app, egui::Key::ArrowDown, M::NONE);
assert_eq!(value(&app), (start + 10.0).ceil() - 1.0, "{label} from {start}");
app.ui.dialog = None;
}
}
#[test]
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
use vectorcraft_doc::NodeId;
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = app();
app.select_tool("polarGrid");
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 30.0, 40.0), Default::default());
}
assert_eq!(DialogKind::of("polarGrid"), Some(DialogKind::Shape));
let d = app.ui.dialog.as_mut().expect("canvas click opens the shared shape form");
assert_eq!(d.fields["width"], json!("100 pt"));
assert_eq!(d.fields["height"], json!("100 pt"));
assert_eq!((d.fields["concentric"].clone(), d.fields["radial"].clone()), (json!(2), json!(3)));
d.fields.insert("width".into(), json!("25.4 mm"));
d.fields.insert("height".into(), json!("0.5 in"));
frame(&mut app, Default::default());
let result = confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let s = app.session.doc().unwrap();
let group = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap();
let children = group.children().unwrap();
assert_eq!(children.len(), 6);
assert_eq!(children.iter().filter(|n| n.path_data().unwrap().is_closed()).count(), 3);
assert_eq!(s.history.undo.len(), undo + 1);
let made = s.doc.clone();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, made);
}
/// Part of gap G11: an Arc tool click opens its options at the pointer; OK draws an arc there.
#[test]
fn arc_click_opens_options_and_ok_draws() {
use vectorcraft_doc::NodeKind;
let mut app = app();
open_tool_dialog(&mut app, "arc", json!({"x": 100.0, "y": 50.0}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.kind, "arc");
assert!(d.fields.contains_key("lengthX") && d.fields.contains_key("lengthY"));
super::confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap();
assert!(matches!(n.kind, NodeKind::Path { .. }));
let pd = n.path_data().unwrap();
let a0 = pd.subpaths[0].anchors[0].p;
let a1 = pd.subpaths[0].anchors[1].p;
assert!((a0.x - 100.0).abs() < 1e-6 && (a0.y - 50.0).abs() < 1e-6);
assert!((a1.x - 200.0).abs() < 1e-6 && (a1.y - 150.0).abs() < 1e-6);
}
/// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path
/// From Ellipses and Fill Grid, opened with the tool's stored values; OK draws with them and
/// stores them.
#[test]
fn polar_grid_dialog_edits_skews_compound_and_fill() {
use vectorcraft_doc::{NodeId, NodeKind};
let mut app = app();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 3, "radial": 4, "radialSkew": 500}})).unwrap();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let labels = [
"Concentric Dividers:",
"Skew:",
"Inside",
"Outside",
"Radial Dividers:",
"Skew:",
"Left",
"Right",
"Create Compound Path From Ellipses",
"Fill Grid",
];
let mut from = 0;
for label in labels {
from += text[from..].find(&format!("{label}\n")).unwrap_or_else(|| panic!("no {label:?} in order in {text}")) + label.len();
}
assert!(!text.contains("Use Outside Rectangle As Frame"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["concentricSkew"].clone(), d.fields["radialSkew"].clone()), (json!(0.0), json!(500.0)));
assert_eq!((d.fields["compound"].clone(), d.fields["fill"].clone()), (json!(false), json!(false)));
for (key, value) in [("concentricSkew", json!(100)), ("compound", json!(true)), ("fill", json!(true))] {
d.fields.insert(key.into(), value);
}
let result = confirm(&mut app).unwrap();
let s = app.session.doc().unwrap();
let children = s.doc.node(NodeId(result["id"].as_u64().unwrap())).unwrap().children().unwrap();
// Two rings under four radial dividers, all in the current fill.
assert_eq!(children.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false, false, false, false]);
assert!(children.iter().all(|n| n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)));
let widths: Vec<f64> = children[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().width()).collect();
assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 93.75).abs() < 1e-6, "outward: {widths:?}");
let tips: Vec<f64> = children[3..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p.x).collect();
assert!(tips.iter().all(|x| *x > 60.0), "right of the top divider: {tips:?}");
let saved = json!({"concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true});
assert_eq!(app.session.tool_options_of("polarGrid"), saved);
}
#[test]
fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
let mut app = app();
app.run("tool.setOption", json!({"tool": "polarGrid", "values": {"concentric": 2, "radial": 3}})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
let d = app.ui.dialog.as_mut().expect("shared Polar Grid form opens");
d.fields.insert("concentric".into(), json!(8));
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["concentric"], json!(2));
for key in ["concentric", "radial"] {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(0));
}
let result = confirm(&mut app).unwrap();
let group = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(result["id"].as_u64().unwrap())).unwrap();
assert_eq!(group.children().unwrap().len(), 1);
assert!(group.children().unwrap()[0].path_data().unwrap().is_closed());
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["radial"], json!(0));
cancel(&mut app);
let layer = app.session.doc().unwrap().doc.layers[0].id;
app.run("layer.setProps", json!({"ids": [layer.0], "locked": true})).unwrap();
let options = app.session.tool_options_of("polarGrid");
let before = app.session.doc().unwrap().doc.clone();
open_tool_dialog(&mut app, "polarGrid", json!({"x": 10, "y": 20}));
app.ui.dialog.as_mut().unwrap().fields.insert("radial".into(), json!(9));
assert!(confirm(&mut app).is_err());
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_options_of("polarGrid"), options);
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// Open the real Preset menu before choosing a Fit; these actions are no longer body buttons.
fn artboard_fit_preset_at(frames: &mut Frames, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
let texts = frames.texts(app);
assert!(texts.iter().all(|(t, _)| !matches!(t.as_str(), "Fit to Artwork Bounds" | "Fit to Selected Art")), "Fit lives only in Preset");
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let combo = frames.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, widgets)| widgets.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()
});
frames.press(app, combo, true);
frames.press(app, combo, false);
frames.texts(app).into_iter().find(|(text, _)| text.trim() == label).unwrap().1.center()
}
#[test]
fn artboard_options_fit_drafts_cancel_and_apply_without_moving_art() {
for selected in [false, true] {
let mut app = app();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.select_tool("artboard");
app.session.set_tool_option("scaleArt", &json!(true));
app.session.set_tool_option("moveArt", &json!(true));
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let label = if selected { "Fit to Selected Art" } else { "Fit to Artwork Bounds" };
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "draft only");
if !apply {
cancel(&mut app);
continue;
}
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, before.layers, "art stays in place");
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
/// Size edits honor the live tool switch; drafts/cancel and one-step history remain intact.
#[test]
fn artboard_options_size_changes_honor_scale_artwork() {
for scale in [false, true] {
for (choice, width, height) in
[(json!(null), 400.0, 200.0), (json!({"preset":[612.0,792.0]}), 792.0, 612.0), (json!({"landscape":false}), 100.0, 200.0)]
{
let mut app = app();
app.run("file.new", json!({"width":200,"height":100})).unwrap();
app.run("shape.rectangle", json!({"x":20,"y":10,"width":40,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.select_tool("artboard");
app.session.set_tool_option("scaleArt", &json!(scale));
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let draft = app.ui.dialog.as_mut().unwrap();
draft.fields.insert("width".into(), json!(width));
draft.fields.insert("height".into(), json!(height));
if !choice.is_null() {
draft.fields.insert("__sizeChoice".into(), choice.clone());
}
assert_eq!(app.session.doc().unwrap().doc, before, "size change remains a draft");
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
continue;
}
let steps = app.session.doc().unwrap().history.undo.len();
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
let bounds = vectorcraft_render::encode::art_bounds(&after).unwrap();
let (sx, sy) = if scale { (width / 200.0, height / 100.0) } else { (1.0, 1.0) };
for (actual, expected) in [(bounds.x0, 20.0 * sx), (bounds.y0, 10.0 * sy), (bounds.x1, 60.0 * sx), (bounds.y1, 30.0 * sy)] {
assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}, scale={scale}, choice={choice}");
}
assert_eq!((after.artboards[0].rect.width(), after.artboards[0].rect.height()), (width, height));
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
app.run("edit.undo", json!({})).unwrap();
}
}
}
}
#[test]
fn artboard_options_shared_preset_drafts_and_resizes_selected_boards_in_their_orientations() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":100,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":150,"height":300})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = |texts: &[(String, egui::Rect)], label: &str| {
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
};
let custom = at(&frames.texts(&mut app), "Custom");
frames.press(&mut app, custom, true);
frames.press(&mut app, custom, false);
let print = at(&frames.texts(&mut app), "Print");
frames.run(&mut app, vec![egui::Event::PointerMoved(print)]);
let letter = at(&frames.texts(&mut app), "Letter");
frames.press(&mut app, letter, true);
frames.press(&mut app, letter, false);
assert_eq!(app.session.doc().unwrap().doc, before, "preset is a draft until OK");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (792.0, 612.0));
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
continue;
}
let steps = app.session.doc().unwrap().history.undo.len();
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (792.0, 612.0));
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (612.0, 792.0));
assert_eq!(doc.artboards[1], before.artboards[1], "unselected board stays unchanged");
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
#[test]
fn artboard_options_orientation_applies_to_other_selected_boards_when_active_is_already_portrait() {
let mut app = app();
app.run("file.new", json!({"width":100,"height":200,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":300,"height":150})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let texts = frames.texts(&mut app);
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let mut buttons: Vec<egui::Rect> = frames.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)
.collect()
});
buttons.sort_by(|a, b| a.left().total_cmp(&b.left()));
let portrait = buttons[buttons.len() - 2].center();
frames.press(&mut app, portrait, true);
frames.press(&mut app, portrait, false);
assert_eq!(app.session.doc().unwrap().doc, before, "orientation remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0], before.artboards[0], "active board was already portrait");
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (150.0, 300.0));
assert_eq!(doc.artboards[1], before.artboards[1]);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_changes_only_active_board_even_with_an_artboard_multiselection() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.session.doc().unwrap().doc, before, "fit remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
assert_eq!(&doc.artboards[1..], &before.artboards[1..]);
assert_eq!(doc.layers, before.layers, "fitting never moves artwork");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_artwork_ignores_draft_membership_and_invalidates_on_edits() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
artboard_options::open(&mut app).unwrap();
let dialog = app.ui.dialog.as_mut().unwrap();
dialog.fields.insert("width".into(), json!(1));
dialog.fields.insert("height".into(), json!(1));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 80.0, 60.0),
"Fit queries all artwork, not the shrunken draft"
);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
// A remote command can edit the document while the modal remains open.
app.run("shape.rectangle", json!({"x":600,"y":120,"width":40,"height":30})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let layers = app.session.doc().unwrap().doc.layers.clone();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 610.0, 110.0),
"A document revision must invalidate cached fit bounds"
);
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
#[test]
fn artboard_options_fit_artwork_includes_all_boards_and_is_independent_of_target() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320,"artboards":2})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
for (index, width, height) in [(0, 80.0, 60.0), (1, 40.0, 30.0)] {
let rect = boards[index].rect;
app.run("shape.rectangle", json!({"x":rect.x0+30.0,"y":rect.y0+40.0,"width":width,"height":height})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
let layers = app.session.doc().unwrap().doc.layers.clone();
let expected =
vectorcraft_geom::Rect::new(boards[0].rect.x0 + 30.0, boards[0].rect.y0 + 40.0, boards[0].rect.x0 + 110.0, boards[0].rect.y0 + 100.0).union(
vectorcraft_geom::Rect::new(boards[1].rect.x0 + 30.0, boards[1].rect.y0 + 40.0, boards[1].rect.x0 + 70.0, boards[1].rect.y0 + 70.0),
);
app.select_tool("artboard");
let mut frames = Frames::new();
for index in [0, 1, 0] {
crate::panels::artboards::select(&mut app, index);
artboard_options::open(&mut app).unwrap();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(expected.x0, expected.y0, expected.width(), expected.height())
);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
}
}
#[test]
fn artboard_options_fit_artwork_new_board_includes_art_outside_its_draft() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":450,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":170,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
let layers = app.session.doc().unwrap().doc.layers.clone();
app.run("ui.newArtboard", json!({})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().bool("new"));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!((dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), (30.0, 40.0, 500.0, 150.0));
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.artboards.clone();
assert_eq!(after.len(), 2);
assert_eq!(after[0], boards[0]);
assert_eq!(after[1].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 530.0, 190.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, after);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
/// Fit and background color share one draft and one undo step for existing and new boards.
#[test]
fn artboard_options_fit_and_background_color_coexist_through_cancel_undo_and_redo() {
for new in [false, true] {
for apply in [false, true] {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("artboard.setProps", json!({"index":0,"color":"#336699"})).unwrap();
let x = if new { 450.0 } else { 30.0 };
app.run("shape.rectangle", json!({"x":x,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let steps = app.session.doc().unwrap().history.undo.len();
if new {
app.run("ui.newArtboard", json!({})).unwrap();
} else {
artboard_options::open(&mut app).unwrap();
}
let labels = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(labels.contains("Background Color:") && labels.contains("Preset:") && !labels.contains("Fit to Artwork Bounds"), "{labels}");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#c05030"));
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["color"], json!("#c05030"));
assert_eq!(app.session.doc().unwrap().doc, before, "both changes remain a draft");
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps);
continue;
}
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
let board = &after.artboards[usize::from(new)];
assert_eq!(board.rect, vectorcraft_geom::Rect::new(x, 40.0, x + 80.0, 100.0));
assert_eq!(board.color.map(|c| c.to_hex()).as_deref(), Some("#c05030"));
assert_eq!(after.layers, before.layers, "fitting never moves the art");
if new {
assert_eq!(after.artboards[0], before.artboards[0]);
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
}
}
/// Moving only the board, with no resize, must not translate its art even when Move Artwork is on.
#[test]
fn artboard_options_position_only_fit_keeps_art_with_move_art_enabled() {
let mut app = app();
app.run("file.new", json!({"width":80,"height":60})).unwrap();
app.run("shape.rectangle", json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":70,"y":50,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("tool.setOption", json!({"tool":"artboard", "values":{"moveArt":true}})).unwrap();
assert!(crate::panels::artboards::move_art(&app));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 10.0, 90.0, 70.0));
assert_eq!(after.layers, before.layers, "position-only fitting never moves art");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
#[test]
fn artboard_options_fit_preset_disabled_without_art_or_selection() {
for has_art in [false, true] {
for label in ["Fit to Artwork Bounds", "Fit to Selected Art"] {
if has_art && label == "Fit to Artwork Bounds" {
continue;
}
let mut app = app();
if has_art {
app.run("shape.rectangle", json!({"x":600,"y":40,"width":80,"height":60})).unwrap();
app.run("select.none", json!({})).unwrap();
}
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let draft = app.ui.dialog.as_ref().unwrap();
assert_ne!(draft.fields.get("__fit"), Some(&json!(true)), "disabled {label} must not become a fit draft");
assert_eq!(app.session.doc().unwrap().doc, before);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
#[test]
fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
for (x, y, w, h) in [(30, 40, 80, 60), (1000, 500, 50, 40)] {
app.run("shape.rectangle", json!({"x":x,"y":y,"width":w,"height":h})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Selected Art");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "selection fit is a draft");
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 1050.0, 540.0));
assert_eq!(after.artboards[1..], before.artboards[1..]);
assert_eq!(after.layers, before.layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}