From 4d17702536a360111e8aeee1d6a381bf9f3a1417 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 20:34:06 +0700 Subject: [PATCH] Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages --- crates/ui-egui/src/dialogs/form.rs | 16 ++++ crates/ui-egui/src/dialogs/tests.rs | 2 +- crates/ui-egui/src/dialogs/transform.rs | 99 ++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 99d0164a..3c5f7198 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -61,6 +61,16 @@ fn lengths(kind: &str) -> &'static [&'static str] { } } +/// The fields of the form dialogs ([`grid`]) that are angles or percentages, with what follows the +/// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do. +fn suffix(kind: &str, key: &str) -> Option<&'static str> { + match (kind, key) { + ("rotate" | "shear", "angle") => Some("°"), + ("scale", "sx" | "sy") => Some("%"), + _ => None, + } +} + /// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by /// name). fn order(kind: &str) -> &'static [&'static str] { @@ -193,6 +203,12 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let options = choices(&d.kind, &k); if lengths.contains(&k.as_str()) { length_field(ui, d, &k, &humanized(&k), unit); + } else if let Some(suffix) = suffix(&d.kind, &k) { + row_label(ui, &humanized(&k)); + if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) { + d.fields.insert(k, json!(v)); + } + ui.end_row(); } else if let Some(ends) = skew_ends(&d.kind, &k) { let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; row_label(ui, tl!("Skew:")); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7a9068aa..3cf0e4cf 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -54,7 +54,7 @@ fn kinds_map_to_their_dialogs() { 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("effect").preview && spec("recolor").preview && !spec("rectangle").preview); assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none()); } diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 0a52c921..8e385b1e 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -1,5 +1,6 @@ -//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns and Copy; -//! Scale also has Uniform, Scale Corners and Scale Strokes & Effects). +//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns, Copy and +//! Preview; Scale also has Uniform, Scale Corners and Scale Strokes & Effects). With Preview on +//! (`preview`, off when the dialog opens), the transform shows on the canvas as the values change. use serde_json::{Value, json}; @@ -7,7 +8,7 @@ use super::{DialogSpec, form, run_and_close}; use crate::VectorcraftApp; use crate::state::Dialog; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM }; fn title(kind: &str) -> &'static str { match kind { @@ -29,6 +30,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d))); form::check(ui, d, "patterns", tl!("Transform Patterns")); form::check(ui, d, "copy", tl!("Copy (make a transformed copy)")); + let (cmd, mut params) = command(app, d); + if d.kind == "scale" { + add_scale_options(app, d, &mut params); + } + form::preview(app, ui, d, title(&d.kind), cmd, params); false } @@ -80,24 +86,37 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value { p } -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { +/// The dialog's command and its params (Scale's without its scale options). +fn command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) { let (copy, patterns) = (d.bool("copy"), transform_patterns(app, d)); let (id, params) = match d.kind.as_str() { - "move" => { - return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy, "patterns": patterns})); - } + // Move has no reference point. + "move" => return ("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy, "patterns": patterns})), "rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy, "patterns": patterns})), "scale" => { let sx = d.f64("sx", 100.0); let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; - ("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy, "patterns": patterns}))?) + ("object.scale", json!({"sx": sx, "sy": sy, "copy": copy, "patterns": patterns})) } "reflect" => { ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy, "patterns": patterns})) } _ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy, "patterns": patterns})), }; - run_and_close(app, id, origin_params(d, params)) + (id, origin_params(d, params)) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // The preview is rolled back and the command run as without one, so its undo step, its journal + // entry and Transform Again are the same either way. + if app.session.in_interaction() { + app.session.cancel_interaction().map_err(|e| e.to_string())?; + } + let (id, mut params) = command(app, d); + if d.kind == "scale" { + params = with_scale_options(app, d, params)?; + } + run_and_close(app, id, params) } #[cfg(test)] @@ -133,6 +152,68 @@ mod tests { assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.scale", &json!(false), &json!(true))); } + /// #1412: Preview shows the transform on the canvas as the values change, ↑/↓ in the angle + /// field step it by 1° (Shift: 10°), Cancel rolls the preview back and OK keeps one undo step + /// that Transform Again repeats. + #[test] + fn preview_follows_the_stepped_angle() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, key: Option<(egui::Key, egui::Modifiers)>| { + let events = key.map(|(key, modifiers)| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + let mut out = + ctx.run_ui(egui::RawInput { events: events.into_iter().collect(), ..Default::default() }, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let size = |app: &VectorcraftApp| { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.width().round(), b.height().round()) + }; + let undo_steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let steps = undo_steps(&app); + crate::menus::invoke(&mut app, "object.rotate", json!({})); + frame(&mut app, None); + assert!(!app.session.in_interaction(), "Preview is off when the dialog opens"); + // ↑ nine times with Shift: 90°, in the dialog only. + for _ in 0..9 { + frame(&mut app, Some((egui::Key::ArrowUp, egui::Modifiers::SHIFT))); + } + frame(&mut app, Some((egui::Key::ArrowUp, egui::Modifiers::NONE))); + frame(&mut app, Some((egui::Key::ArrowDown, egui::Modifiers::NONE))); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["angle"], json!(90.0)); + assert_eq!(size(&app), (60.0, 20.0)); + // Preview on: the canvas shows it, and follows the next steps. + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + frame(&mut app, None); + assert!(app.session.in_interaction()); + assert_eq!(size(&app), (20.0, 60.0)); + for _ in 0..9 { + frame(&mut app, Some((egui::Key::ArrowUp, egui::Modifiers::SHIFT))); + } + assert_eq!(size(&app), (60.0, 20.0), "180°"); + // Cancel rolls it back. + for _ in 0..9 { + frame(&mut app, Some((egui::Key::ArrowDown, egui::Modifiers::SHIFT))); + } + crate::dialogs::cancel(&mut app); + assert!(!app.session.in_interaction()); + assert_eq!((size(&app), undo_steps(&app)), ((60.0, 20.0), steps)); + // OK keeps the previewed transform as one undo step, and Transform Again repeats it. + crate::menus::invoke(&mut app, "object.rotate", json!({})); + app.ui.dialog.as_mut().unwrap().fields.extend([("preview".to_string(), json!(true)), ("angle".to_string(), json!(90))]); + frame(&mut app, None); + assert_eq!(size(&app), (20.0, 60.0)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction() && app.ui.dialog.is_none()); + assert_eq!((size(&app), undo_steps(&app)), ((20.0, 60.0), steps + 1)); + assert_eq!(app.session.journal.last().unwrap().0, "object.rotate"); + app.run("object.transformAgain", json!({})).unwrap(); + assert_eq!(size(&app), (60.0, 20.0)); + } + /// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the /// command. #[test]