mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
570 lines
28 KiB
Rust
570 lines
28 KiB
Rust
//! The dialog registry and the shared frame, drawn headlessly.
|
||
|
||
use serde_json::json;
|
||
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),
|
||
("rectangularGrid", 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("move").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",
|
||
"rectangularGrid",
|
||
"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);
|
||
}
|
||
|
||
#[test]
|
||
fn raster_filter_dialogs_preview_and_apply() {
|
||
for (effect, key, value) in [
|
||
("blur.radial", "method", "zoom"),
|
||
("blur.smart", "quality", "high"),
|
||
("sharpen.unsharpMask", "amount", "120"),
|
||
("pixelate.colorHalftone", "channel1", "30"),
|
||
("pixelate.crystallize", "cellSize", "20"),
|
||
("pixelate.mezzotint", "type", "longLines"),
|
||
("pixelate.pointillize", "cellSize", "12"),
|
||
("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"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||
assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{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("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 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 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.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());
|
||
}
|
||
|
||
#[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!({}));
|
||
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");
|
||
}
|
||
|
||
#[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)));
|
||
}
|
||
|
||
/// #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",
|
||
"Preview",
|
||
] {
|
||
assert!(text.lines().any(|l| l == s), "{s} in {text}");
|
||
}
|
||
assert!(app.session.in_interaction(), "the preview runs");
|
||
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));
|
||
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));
|
||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
|
||
assert!(text.lines().any(|l| l == "Reference Point"), "{text}");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
|
||
/// 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:?}");
|
||
}
|