mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 08:23:20 +00:00
240 lines
12 KiB
Rust
240 lines
12 KiB
Rust
//! 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};
|
||
|
||
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, preview: true, ..DialogSpec::FORM };
|
||
|
||
fn title(kind: &str) -> &'static str {
|
||
match kind {
|
||
"move" => tl!("Move"),
|
||
"rotate" => tl!("Rotate"),
|
||
"scale" => tl!("Scale"),
|
||
"reflect" => tl!("Reflect"),
|
||
_ => tl!("Shear"),
|
||
}
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
form::grid(ui, d, app.session.general_unit());
|
||
ui.add_space(6.0);
|
||
if d.kind == "scale" {
|
||
form::check(ui, d, "uniform", tl!("Uniform"));
|
||
scale_options(app, ui, d);
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Transform Patterns: the dialog's field, else General › Transform Pattern Tiles.
|
||
fn transform_patterns(app: &VectorcraftApp, d: &Dialog) -> bool {
|
||
d.fields.get("patterns").and_then(Value::as_bool).unwrap_or(app.session.prefs.transform_pattern_tiles)
|
||
}
|
||
|
||
/// The scale options: (dialog field, preference, label).
|
||
const SCALE_OPTIONS: [(&str, &str, &str); 2] = [("corners", "scaleCorners", "Scale Corners"), ("strokes", "scaleStrokes", "Scale Strokes & Effects")];
|
||
|
||
/// A scale option's value: the dialog's field, else the preference.
|
||
fn scale_option(app: &VectorcraftApp, d: &Dialog, field: &str) -> bool {
|
||
let prefs = &app.session.prefs;
|
||
d.fields.get(field).and_then(Value::as_bool).unwrap_or(if field == "corners" { prefs.scale_corners } else { prefs.scale_strokes })
|
||
}
|
||
|
||
/// The Scale Corners and Scale Strokes & Effects checkboxes (fields `corners` and `strokes`),
|
||
/// starting from the preferences.
|
||
pub(super) fn scale_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
for (field, _, label) in SCALE_OPTIONS {
|
||
let on = scale_option(app, d, field);
|
||
d.fields.insert(field.into(), Value::Bool(on));
|
||
form::check(ui, d, field, tl!(label));
|
||
}
|
||
}
|
||
|
||
/// Add the dialog's scale options to command `params`.
|
||
pub(super) fn add_scale_options(app: &VectorcraftApp, d: &Dialog, params: &mut Value) {
|
||
for (field, _, _) in SCALE_OPTIONS {
|
||
params[field] = json!(scale_option(app, d, field));
|
||
}
|
||
}
|
||
|
||
/// `params` with the dialog's scale options, which also become the preferences (they are shared
|
||
/// with the Transform panel and Preferences, as in the reference app).
|
||
pub(super) fn with_scale_options(app: &mut VectorcraftApp, d: &Dialog, mut params: Value) -> Result<Value, String> {
|
||
add_scale_options(app, d, &mut params);
|
||
let prefs: serde_json::Map<String, Value> = SCALE_OPTIONS.iter().map(|(field, pref, _)| (pref.to_string(), params[*field].clone())).collect();
|
||
app.run("prefs.set", json!({ "values": prefs }))?;
|
||
Ok(params)
|
||
}
|
||
|
||
/// Pass a tool-chosen reference point (origin) through to transform commands.
|
||
fn origin_params(d: &Dialog, mut p: Value) -> Value {
|
||
if let Some(o) = d.fields.get("origin") {
|
||
p["origin"] = o.clone();
|
||
}
|
||
p
|
||
}
|
||
|
||
/// 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 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", 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})),
|
||
};
|
||
(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)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use crate::VectorcraftApp;
|
||
|
||
#[test]
|
||
fn scale_options_start_from_and_update_the_preferences() {
|
||
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": 0, "y": 0, "width": 50, "height": 50, "radius": 5})).unwrap();
|
||
app.run("select.all", json!({})).unwrap();
|
||
app.run("prefs.set", json!({"values": {"scaleStrokes": true, "scaleCorners": false}})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.scale", json!({}));
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
assert_eq!((&d.fields["strokes"], &d.fields["corners"]), (&json!(true), &json!(false)));
|
||
d.fields.insert("sx".into(), json!(200));
|
||
d.fields.insert("strokes".into(), json!(false));
|
||
d.fields.insert("corners".into(), json!(true));
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
let prefs = &app.session.prefs;
|
||
assert_eq!((prefs.scale_strokes, prefs.scale_corners), (false, true));
|
||
let n = &app.session.doc().unwrap().doc.layers[0].children().unwrap()[0];
|
||
assert_eq!(n.appearance.stroke_width(), 1.0);
|
||
let (cmd, p) = app.session.journal.last().unwrap();
|
||
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]
|
||
fn transform_patterns_starts_from_the_preference() {
|
||
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": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
for on in [false, true] {
|
||
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": on})).unwrap();
|
||
crate::menus::invoke(&mut app, "object.move", json!({}));
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
assert_eq!(d.fields["patterns"], json!(on));
|
||
d.fields.insert("dx".into(), json!(10));
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
let (cmd, p) = app.session.journal.last().unwrap();
|
||
assert_eq!((cmd.as_str(), &p["patterns"]), ("object.move", &json!(on)));
|
||
}
|
||
}
|
||
}
|