mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
A plug-in's dialog (Effect › Plug-ins, Object › Plug-ins) lays out each parameter of its manifest as a row, and a manifest may declare up to 64. With many of them the dialog ran off the top and the bottom of the window, its heading and its OK and Cancel buttons off screen. A plug-in of 64 number parameters got a dialog 2,329 px tall, its top at y = -755 in a 900 px window. modal::scrolled_rows() puts rows in a vertical scroll area as tall as the screen less the rest of the modal window and its edge gaps. modal::show_at() measures that rest once it has drawn the window, for the next frame, and asks for that frame at once if the rest changed. The dialog then fits without waiting for input. schema_fields() puts the parameter grid in it, for filters and effects alike. A dialog of a few parameters is as tall as before. a_plug_in_dialog_of_many_settings_fits_the_window opens an effect's and a filter's dialog of 64 parameters in a 1280 × 900 window, and one of 4. Before the change it failed with "effect: the dialog is at [[457.0 -755.0] - [823.0 1574.0]] in the [1280.0 900.0] window". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
452 lines
24 KiB
Rust
452 lines
24 KiB
Rust
//! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and
|
||
//! the form dialogs' choice fields are dropdowns (Offset Path's Joins). A plug-in's dialog of more
|
||
//! settings than the window has room for fits it, its settings scrolling.
|
||
|
||
use egui::{Event, Pos2, Rect, Vec2, vec2};
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use super::*;
|
||
use crate::theme;
|
||
|
||
const SCREEN: Vec2 = vec2(1280.0, 900.0);
|
||
|
||
/// A context drawing the dialog layer in a 1280 × 900 window.
|
||
fn ctx() -> egui::Context {
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
theme::apply(&ctx, Default::default());
|
||
ctx
|
||
}
|
||
|
||
/// One frame of the dialog layer with `events`: every text drawn, with its screen rect.
|
||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||
match s {
|
||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||
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
|
||
}
|
||
|
||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
/// Drag with the mouse from `from` by `by`, in steps, then let go.
|
||
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: Pos2, by: Vec2) {
|
||
frame(ctx, app, vec![Event::PointerMoved(from), button(from, true)]);
|
||
for i in 1..=6 {
|
||
frame(ctx, app, vec![Event::PointerMoved(from + by * (i as f32 / 6.0))]);
|
||
}
|
||
frame(ctx, app, vec![button(from + by, false)]);
|
||
frame(ctx, app, vec![]);
|
||
}
|
||
|
||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: Pos2) -> Vec<(String, Rect)> {
|
||
frame(ctx, app, vec![Event::PointerMoved(pos), button(pos, true)]);
|
||
frame(ctx, app, vec![button(pos, false)]);
|
||
frame(ctx, app, vec![])
|
||
}
|
||
|
||
/// Frames until a window that just opened has measured and placed itself.
|
||
fn settle(ctx: &egui::Context, app: &mut VectorcraftApp) {
|
||
for _ in 0..6 {
|
||
frame(ctx, app, vec![]);
|
||
}
|
||
}
|
||
|
||
fn rect_of(ctx: &egui::Context, id: egui::Id) -> Rect {
|
||
ctx.memory(|m| m.area_rect(id)).expect("the dialog window was shown")
|
||
}
|
||
|
||
fn app_with_selection() -> VectorcraftApp {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap();
|
||
app
|
||
}
|
||
|
||
fn close_to(a: Vec2, b: Vec2) -> bool {
|
||
(a - b).length() < 1.5
|
||
}
|
||
|
||
/// Opens a dialog.
|
||
type Open = fn(&mut VectorcraftApp);
|
||
|
||
/// Every kind of modal window: the shared frame (Offset Path), and the ones drawing their own
|
||
/// window (New Document without a heading, Preferences, Keyboard Shortcuts).
|
||
#[test]
|
||
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
|
||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||
let opens: [(&str, Open, egui::Id); 4] = [
|
||
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
|
||
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
|
||
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
|
||
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
|
||
];
|
||
for (name, open, id) in opens {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
open(&mut app);
|
||
settle(&ctx, &mut app);
|
||
let start = rect_of(&ctx, id);
|
||
// Dragging the heading (the band from the top edge down to it) moves the window.
|
||
drag(&ctx, &mut app, start.left_top() + vec2(40.0, 10.0), vec2(-90.0, 50.0));
|
||
let moved = rect_of(&ctx, id);
|
||
assert!(close_to(moved.min - start.min, vec2(-90.0, 50.0)), "{name}: {start:?} → {moved:?}");
|
||
// A drag that starts below the heading (in the bottom margin) doesn't.
|
||
drag(&ctx, &mut app, moved.left_bottom() + vec2(6.0, -4.0), vec2(80.0, -80.0));
|
||
assert_eq!(rect_of(&ctx, id), moved, "{name}: dragging the body moved it");
|
||
// Dragged far past the window's edge, it stops at the edge.
|
||
drag(&ctx, &mut app, moved.left_top() + vec2(40.0, 10.0), vec2(5000.0, 5000.0));
|
||
let edge = rect_of(&ctx, id);
|
||
assert!(screen.contains_rect(edge.shrink(0.5)), "{name}: off screen at {edge:?}");
|
||
assert!((edge.right() - screen.right()).abs() < 1.5 && (edge.bottom() - screen.bottom()).abs() < 1.5, "{name}: {edge:?}");
|
||
// Closed and opened again (this session): where it was left.
|
||
app.ui.dialog = None;
|
||
frame(&ctx, &mut app, vec![]);
|
||
open(&mut app);
|
||
settle(&ctx, &mut app);
|
||
assert!(close_to(rect_of(&ctx, id).min - edge.min, Vec2::ZERO), "{name}: reopened at {:?}, left at {edge:?}", rect_of(&ctx, id));
|
||
}
|
||
}
|
||
|
||
/// Open the dialog of a plug-in of `kind` (`effect` or `filter`) with `count` number settings over a
|
||
/// selection, settle it and `check` it, given its window and heading. The other tests share the
|
||
/// plug-in registry, and the plug-in is removed after.
|
||
fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Context, &mut VectorcraftApp, egui::Id, &str)) {
|
||
use vectorcraft_plugins::wat as tpl;
|
||
let params: Vec<String> = (0..count).map(|i| format!(r#""setting{i}":{{"type":"number","min":0,"max":10,"default":1}}"#)).collect();
|
||
let id = format!("test.ui.{kind}{count}");
|
||
let src = tpl::module(&tpl::manifest(&id, kind, &format!("{{{}}}", params.join(","))), tpl::ECHO, "", 1);
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
|
||
let (command, params, dialog) = match kind {
|
||
"effect" => ("effect.dialog", json!({"effect": vectorcraft_plugins::effect::effect_id(&id)}), "effect"),
|
||
_ => ("plugin.dialog", json!({"id": id}), plugin::KIND),
|
||
};
|
||
app.run(command, params).unwrap();
|
||
settle(&ctx, &mut app);
|
||
check(&ctx, &mut app, egui::Id::new(("dialog", dialog)), &format!("Test {id}"));
|
||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||
}
|
||
|
||
/// A plug-in can declare more settings than the window has room for, a row each. Its dialog, an
|
||
/// effect's or a filter's, fits the window all the same, heading and buttons included. The
|
||
/// settings take the height there is, and the wheel scrolls them to the last one. A dialog of a few
|
||
/// settings is as tall as they are.
|
||
#[test]
|
||
fn a_plug_in_dialog_of_many_settings_fits_the_window() {
|
||
use vectorcraft_plugins::manifest::MAX_PARAMS;
|
||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||
let shown = |texts: &[(String, Rect)], text: &str| texts.iter().find(|(t, r)| t == text && screen.contains_rect(*r)).map(|(_, r)| *r);
|
||
for kind in ["effect", "filter"] {
|
||
check_plugin_dialog(kind, MAX_PARAMS, |ctx, app, window, heading| {
|
||
let last = format!("Setting{}:", MAX_PARAMS - 1);
|
||
let texts = frame(ctx, app, vec![]);
|
||
let rect = rect_of(ctx, window);
|
||
assert!(screen.contains_rect(rect) && rect.height() > 0.75 * SCREEN.y, "{kind}: the dialog is at {rect:?} in the {SCREEN:?} window");
|
||
for text in [heading, "Setting0:", "OK", "Cancel"] {
|
||
assert!(shown(&texts, text).is_some(), "{kind}: {text} isn't on screen: {texts:?}");
|
||
}
|
||
assert!(shown(&texts, &last).is_none(), "{kind}: {last} is out of view at first");
|
||
let over = shown(&texts, "Setting0:").unwrap().center();
|
||
let wheel = Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
delta: vec2(0.0, -200.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
};
|
||
frame(ctx, app, vec![Event::PointerMoved(over), wheel]);
|
||
for _ in 0..30 {
|
||
frame(ctx, app, vec![]);
|
||
}
|
||
let texts = frame(ctx, app, vec![]);
|
||
for text in [heading, last.as_str(), "OK", "Cancel"] {
|
||
assert!(shown(&texts, text).is_some(), "{kind}, scrolled to the end: {text} isn't on screen: {texts:?}");
|
||
}
|
||
assert_eq!(rect_of(ctx, window), rect, "{kind}: scrolling leaves the window as it was");
|
||
});
|
||
}
|
||
check_plugin_dialog("effect", 4, |ctx, app, window, _| {
|
||
let texts = frame(ctx, app, vec![]);
|
||
let rect = rect_of(ctx, window);
|
||
assert!(rect.height() < SCREEN.y / 2.0 && shown(&texts, "Setting3:").is_some(), "4 settings: {rect:?} {texts:?}");
|
||
});
|
||
}
|
||
|
||
/// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after
|
||
/// Offset; choosing Round runs the command with round joins.
|
||
#[test]
|
||
fn offset_path_joins_is_a_dropdown() {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("select.all", json!({})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||
frame(&ctx, &mut app, vec![]);
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
let at = |texts: &[(String, Rect)], s: &str| texts.iter().find(|(t, _)| t == s).map(|(_, r)| *r);
|
||
let (offset, joins, miter) = (at(&texts, "Offset:").unwrap(), at(&texts, "Joins:").unwrap(), at(&texts, "Miter Limit:").unwrap());
|
||
assert!(offset.top() < joins.top() && joins.top() < miter.top(), "Offset, Joins, Miter Limit: {texts:?}");
|
||
let current = at(&texts, "Miter").expect("Joins shows its choice");
|
||
assert!(at(&texts, "miter").is_none(), "Joins is a text field: {texts:?}");
|
||
// The list: Miter, Round, Bevel.
|
||
let open = click(&ctx, &mut app, current.center());
|
||
for choice in ["Round", "Bevel"] {
|
||
assert!(at(&open, choice).is_some(), "{choice} isn't listed: {open:?}");
|
||
}
|
||
let round = at(&open, "Round").unwrap();
|
||
click(&ctx, &mut app, round.center());
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["joins"], json!("round"));
|
||
// OK: the same result as the command with round joins (not miter).
|
||
confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
let made = |app: &VectorcraftApp| {
|
||
let st = app.session.doc().unwrap();
|
||
format!("{:?}", st.doc.node(st.selection.objects[0]).unwrap().kind)
|
||
};
|
||
let direct = |joins: &str| {
|
||
let mut app = app_with_selection();
|
||
app.run("select.all", json!({})).unwrap();
|
||
app.run("object.path.offsetPath", json!({"offset": 10, "joins": joins, "miterLimit": 4})).unwrap();
|
||
made(&app)
|
||
};
|
||
assert_eq!(made(&app), direct("round"));
|
||
assert_ne!(made(&app), direct("miter"));
|
||
}
|
||
|
||
/// The other form dialogs' choices are dropdowns too: Average's and Shear's axis.
|
||
#[test]
|
||
fn form_dialog_axes_are_dropdowns() {
|
||
for (id, shown) in [("path.average", "Both"), ("object.shear", "Horizontal"), ("object.reflect", "Vertical")] {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("select.all", json!({})).unwrap();
|
||
crate::menus::invoke(&mut app, id, json!({}));
|
||
frame(&ctx, &mut app, vec![]);
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
assert!(texts.iter().any(|(t, _)| t == shown), "{id}: {texts:?}");
|
||
assert!(!texts.iter().any(|(t, _)| *t == shown.to_lowercase()), "{id} has a text field: {texts:?}");
|
||
}
|
||
}
|
||
|
||
/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and
|
||
/// Vertical are the type orientations (horizontal and vertical writing).
|
||
#[test]
|
||
fn axis_choices_read_as_axes_in_every_complete_language() {
|
||
use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx};
|
||
let ja = Lang::from_code("ja").unwrap();
|
||
assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向");
|
||
assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal"));
|
||
for l in LANGUAGES.iter().filter(|l| l.complete_menus) {
|
||
for s in ["Horizontal", "Vertical", "Both"] {
|
||
assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Offset Path previews on the canvas; Cancel rolls the preview back.
|
||
#[test]
|
||
fn offset_path_previews() {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("select.all", json!({})).unwrap();
|
||
let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>();
|
||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
let texts = if texts.is_empty() { frame(&ctx, &mut app, vec![]) } else { texts };
|
||
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
|
||
click(&ctx, &mut app, preview.center());
|
||
frame(&ctx, &mut app, vec![]);
|
||
assert_eq!(count(&app), 2, "previewed");
|
||
cancel(&mut app);
|
||
assert_eq!(count(&app), 1, "rolled back");
|
||
}
|
||
|
||
/// Offset Path opens on the values it was last confirmed with (#1152).
|
||
#[test]
|
||
fn offset_path_remembers_its_last_values() {
|
||
let mut app = app_with_selection();
|
||
app.run("select.all", json!({})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
d.fields.insert("offset".into(), json!(-4.5));
|
||
d.fields.insert("joins".into(), json!("round"));
|
||
confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
crate::menus::invoke(&mut app, "object.path.offsetPath", json!({}));
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!((d.f64("offset", 0.0), d.str("joins"), d.f64("miterLimit", 0.0)), (-4.5, "round".to_string(), 4.0));
|
||
}
|
||
|
||
fn app_with_dense_path() -> VectorcraftApp {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let anchors: Vec<_> = (0..20).map(|i| json!({"x": 30 + i * 15, "y": 100.0 + (i % 2) as f64 * 0.1})).collect();
|
||
let id = app.run("path.create", json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 19]]})).unwrap();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn simplify_dialog_preview_counts_and_cancel_restore_the_original() {
|
||
let (ctx, mut app) = (ctx(), app_with_dense_path());
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let selection = app.session.doc().unwrap().selection.clone();
|
||
let undo_len = app.session.doc().unwrap().history.undo.len();
|
||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||
settle(&ctx, &mut app);
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
let preview = texts.iter().find(|(t, _)| t == "Preview").map(|(_, r)| *r).expect("a Preview checkbox");
|
||
assert!(texts.iter().any(|(t, _)| t == "Anchor Points: 20 → 2"), "the reduction is visible: {texts:?}");
|
||
assert!(app.session.in_interaction());
|
||
assert!(app.session.doc().unwrap().selection.anchors.is_empty());
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len);
|
||
// The dialog focuses its first field. Type as a user does, then Tab commits it
|
||
// without confirming the dialog; replacing only its bound value leaves old focused text.
|
||
frame(&ctx, &mut app, vec![Event::Text("0.01 pt".into())]);
|
||
frame(&ctx, &mut app, vec![Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||
frame(&ctx, &mut app, vec![]);
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("tolerance", 0.0), 0.01);
|
||
let fine = app.session.doc().unwrap().doc.clone();
|
||
click(&ctx, &mut app, preview.center());
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
assert_eq!(app.session.doc().unwrap().selection, selection);
|
||
assert!(!app.session.in_interaction());
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
assert!(!texts.iter().any(|(t, _)| t.starts_with("Anchor Points:")), "no stale counts while Preview is off");
|
||
let preview = texts.iter().find(|(t, _)| t == "Preview").unwrap().1;
|
||
click(&ctx, &mut app, preview.center());
|
||
assert_eq!(app.session.doc().unwrap().doc, fine);
|
||
cancel(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
assert_eq!(app.session.doc().unwrap().selection, selection);
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len);
|
||
app.run("object.path.simplify", json!({"tolerance": 0.01})).unwrap();
|
||
assert_eq!(app.session.doc().unwrap().doc, fine, "changing tolerance refits the original, not the previous preview");
|
||
}
|
||
|
||
#[test]
|
||
fn simplify_dialog_ok_keeps_exactly_one_undo_with_or_without_preview() {
|
||
for on in [true, false] {
|
||
let (ctx, mut app) = (ctx(), app_with_dense_path());
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let selection = app.session.doc().unwrap().selection.clone();
|
||
let undo_len = app.session.doc().unwrap().history.undo.len();
|
||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(on));
|
||
settle(&ctx, &mut app);
|
||
if on {
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("tolerance".into(), json!("0.01 pt"));
|
||
frame(&ctx, &mut app, vec![]);
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("tolerance".into(), json!("1 pt"));
|
||
frame(&ctx, &mut app, vec![]);
|
||
}
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len);
|
||
confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||
assert!(app.session.doc().unwrap().selection.anchors.is_empty());
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1);
|
||
let after = app.session.doc().unwrap().doc.clone();
|
||
assert_ne!(after, before);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
assert_eq!(app.session.doc().unwrap().selection, selection);
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.doc().unwrap().doc, after);
|
||
assert!(app.session.doc().unwrap().selection.anchors.is_empty());
|
||
}
|
||
}
|
||
|
||
/// Whether every segment of every path in `app`'s document is straight (false with no segments).
|
||
fn only_straight_segments(app: &VectorcraftApp) -> bool {
|
||
let mut lines = vec![];
|
||
app.session.doc().unwrap().doc.walk(|n| {
|
||
if let vectorcraft_doc::NodeKind::Path { path, .. } = &n.kind {
|
||
for sp in &path.subpaths {
|
||
lines.extend((0..sp.segment_count()).map(|i| sp.segment_is_line(i)));
|
||
}
|
||
}
|
||
});
|
||
!lines.is_empty() && lines.iter().all(|l| *l)
|
||
}
|
||
|
||
/// Simplify's dialog sends Corner Point Angle Threshold and Convert to Straight Lines with the
|
||
/// tolerance, and ticking Convert to Straight Lines previews straight segments only.
|
||
#[test]
|
||
fn simplify_dialog_sends_corner_angle_and_straight_lines() {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("shape.ellipse", json!({"x": 100, "y": 200, "width": 160, "height": 90})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||
settle(&ctx, &mut app);
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
assert!(texts.iter().any(|(t, _)| t == "Corner Point Angle Threshold:"), "{texts:?}");
|
||
let previewed = |app: &VectorcraftApp| app.session.doc().unwrap().interaction.as_ref().and_then(|it| it.preview.clone()).expect("a preview").1;
|
||
assert_eq!((&previewed(&app)["cornerAngle"], &previewed(&app)["straightLines"]), (&json!(30.0), &json!(false)));
|
||
assert!(!only_straight_segments(&app), "the fit keeps the ellipse's curves");
|
||
let straight = texts.iter().find(|(t, _)| t == "Convert to Straight Lines").map(|(_, r)| *r).expect("a Convert to Straight Lines checkbox");
|
||
click(&ctx, &mut app, straight.center());
|
||
assert_eq!(previewed(&app)["straightLines"], json!(true));
|
||
assert!(only_straight_segments(&app), "Convert to Straight Lines previews straight segments only");
|
||
// A higher threshold keeps more corners: the command's turn angle goes down as it goes up.
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("angleThreshold".into(), json!(170));
|
||
frame(&ctx, &mut app, vec![]);
|
||
assert_eq!(previewed(&app)["cornerAngle"], json!(10.0));
|
||
confirm(&mut app).unwrap();
|
||
assert!(only_straight_segments(&app), "OK keeps the straight segments");
|
||
}
|
||
|
||
/// Corner Point Angle Threshold: an octagon's 135° corners stay corners at the 150° the dialog opens
|
||
/// with, and a threshold below 135° smooths them into curves.
|
||
#[test]
|
||
fn simplify_dialog_corner_point_angle_threshold_keeps_or_smooths_corners() {
|
||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||
app.run("edit.clear", json!({})).unwrap();
|
||
app.run("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 100, "sides": 8})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("tolerance".into(), json!("10 pt"));
|
||
settle(&ctx, &mut app);
|
||
assert!(only_straight_segments(&app), "the corners stay and the sides stay straight");
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("angleThreshold".into(), json!(120));
|
||
frame(&ctx, &mut app, vec![]);
|
||
assert!(!only_straight_segments(&app), "the corners are smoothed into curves");
|
||
}
|
||
|
||
/// Show Original Path: while Simplify previews with it on, the canvas outlines the paths as they
|
||
/// were before the preview.
|
||
#[test]
|
||
fn simplify_dialog_show_original_path_outlines_the_paths_before_the_preview() {
|
||
let (ctx, mut app) = (ctx(), app_with_dense_path());
|
||
let st = app.session.doc().unwrap();
|
||
let (id, original) = match st.doc.node(st.selection.objects[0]).map(|n| (n.id, &n.kind)) {
|
||
Some((id, vectorcraft_doc::NodeKind::Path { path, .. })) => (id, path.clone()),
|
||
_ => panic!("a path is selected"),
|
||
};
|
||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||
settle(&ctx, &mut app);
|
||
let texts = frame(&ctx, &mut app, vec![]);
|
||
assert!(super::path_ops::original_paths(&app).is_empty(), "Show Original Path is off as the dialog opens");
|
||
let show = texts.iter().find(|(t, _)| t == "Show Original Path").map(|(_, r)| *r).expect("a Show Original Path checkbox");
|
||
click(&ctx, &mut app, show.center());
|
||
assert_eq!(super::path_ops::original_paths(&app), vec![(id, &original)]);
|
||
let simplified = app.session.doc().unwrap().doc.node(id).map(|n| n.kind.clone());
|
||
assert!(!matches!(simplified, Some(vectorcraft_doc::NodeKind::Path { path, .. }) if path == original), "the preview changed the path");
|
||
let preview = frame(&ctx, &mut app, vec![]).into_iter().find(|(t, _)| t == "Preview").map(|(_, r)| r).expect("a Preview checkbox");
|
||
click(&ctx, &mut app, preview.center());
|
||
assert!(super::path_ops::original_paths(&app).is_empty(), "nothing to compare with while Preview is off");
|
||
cancel(&mut app);
|
||
assert!(super::path_ops::original_paths(&app).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn printer_mark_labels_are_distinct_from_language_names() {
|
||
let cs = crate::i18n::Lang::from_code("cs").unwrap();
|
||
assert_eq!(save_pdf::choice_label(cs, "marks.kind", "Japanese"), "Japonské");
|
||
assert_eq!(save_pdf::choice_label(cs, "language", "Japanese"), "Japonština");
|
||
assert_eq!(save_pdf::choice_label(crate::i18n::Lang::EN, "marks.kind", "Japanese"), "Japanese");
|
||
}
|