Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages

This commit is contained in:
bflatastic 2026-10-12 20:34:06 +07:00
parent a41d59604b
commit 4d17702536
3 changed files with 107 additions and 10 deletions

View File

@ -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 /// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
/// name). /// name).
fn order(kind: &str) -> &'static [&'static str] { 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); let options = choices(&d.kind, &k);
if lengths.contains(&k.as_str()) { if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanized(&k), unit); 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) { } else if let Some(ends) = skew_ends(&d.kind, &k) {
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
row_label(ui, tl!("Skew:")); row_label(ui, tl!("Skew:"));

View File

@ -54,7 +54,7 @@ fn kinds_map_to_their_dialogs() {
assert_eq!(DialogKind::of(kind), Some(want), "{kind}"); assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
} }
assert_eq!(DialogKind::of("nope"), None); 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()); assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
} }

View File

@ -1,5 +1,6 @@
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns and Copy; //! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns, Copy and
//! Scale also has Uniform, Scale Corners and Scale Strokes & Effects). //! 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}; use serde_json::{Value, json};
@ -7,7 +8,7 @@ use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp; use crate::VectorcraftApp;
use crate::state::Dialog; 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 { fn title(kind: &str) -> &'static str {
match kind { 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))); d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d)));
form::check(ui, d, "patterns", tl!("Transform Patterns")); form::check(ui, d, "patterns", tl!("Transform Patterns"));
form::check(ui, d, "copy", tl!("Copy (make a transformed copy)")); 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 false
} }
@ -80,24 +86,37 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value {
p p
} }
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { /// 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 (copy, patterns) = (d.bool("copy"), transform_patterns(app, d));
let (id, params) = match d.kind.as_str() { let (id, params) = match d.kind.as_str() {
"move" => { // Move has no reference point.
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" => 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})), "rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy, "patterns": patterns})),
"scale" => { "scale" => {
let sx = d.f64("sx", 100.0); let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 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" => { "reflect" => {
("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy, "patterns": patterns})) ("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})), _ => ("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<Value, String> {
// 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)] #[cfg(test)]
@ -133,6 +152,68 @@ mod tests {
assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.scale", &json!(false), &json!(true))); 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 /// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the
/// command. /// command.
#[test] #[test]