Move dialog opens on the last drag or Move, with its last checkboxes

This commit is contained in:
Dan Green
2026-10-17 15:14:43 +07:00
committed by bflatastic
parent b9309f7985
commit 6daf74fb6d
8 changed files with 203 additions and 12 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ pub mod text_import;
mod tiff_bmp_tga;
pub mod tile_edge_color;
mod tools;
mod transform;
pub(crate) mod transform;
pub mod transform_each;
pub mod variables;
mod warp_effect;
+121 -3
View File
@@ -1,7 +1,11 @@
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Objects, Transform
//! Patterns, Copy and Preview; Scale also has Uniform, Scale Corners and Scale Strokes & Effects;
//! Reflect's axis is Horizontal, Vertical or an angle). With Preview on
//! (`preview`, off when the dialog opens), the transform shows on the canvas as the values change.
//! Reflect's axis is Horizontal, Vertical or an angle). With Preview on (`preview`), the transform
//! shows on the canvas as the values change.
//!
//! Move opens on the offset moved last, by a drag or by itself, and with the checkboxes (Preview
//! too) it was last confirmed with, so Enter, Enter repeats a move or a copy at the same spacing.
//! The other dialogs open with Preview off.
use serde_json::{Value, json};
@@ -72,6 +76,42 @@ fn clean(v: f64) -> f64 {
(v * 1e6).round() / 1e6 + 0.0
}
/// The Move dialog's fields as it opens: Horizontal and Vertical are the offset moved last, and
/// Copy, Transform Objects and Preview are as it was last confirmed with (Transform Patterns is
/// General › Transform Pattern Tiles, which its OK sets).
pub(crate) fn move_fields(app: &VectorcraftApp) -> Value {
let (dx, dy) = last_move(app).unwrap_or_default();
let mut fields = json!({"dx": format!("{} pt", clean(dx)), "dy": format!("{} pt", clean(dy))});
let kept = &app.ui.move_options;
let kept_bool = |k: &str| kept.get(k).and_then(Value::as_bool);
for k in ["copy", "objects", "preview"] {
if let Some(on) = kept_bool(k) {
fields[k] = json!(on);
}
}
fields
}
/// The offset of the last move the session's journal keeps: a drag that moved the selection (a
/// Transform that only translates) or Move.
fn last_move(app: &VectorcraftApp) -> Option<(f64, f64)> {
let length = |v: Option<&Value>| match v {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => vectorcraft_doc::Unit::Points.parse(s),
_ => Some(0.0),
};
let finite = |(dx, dy): (f64, f64)| (dx.is_finite() && dy.is_finite()).then_some((dx, dy));
app.session.journal.iter().rev().find_map(|(id, p)| match id.as_str() {
"object.move" => finite((length(p.get("dx"))?, length(p.get("dy"))?)),
"object.transform" => {
let [a, b, c, d, e, f] = vectorcraft_engine::cmd::matrix_param(p, "matrix")?.as_coeffs();
let translates = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && (e, f) != (0.0, 0.0);
translates.then_some((e, f)).and_then(finite)
}
_ => None,
})
}
/// 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)
@@ -171,7 +211,16 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.kind == "scale" {
params = with_scale_options(app, d, params)?;
}
run_and_close(app, id, params)
let r = run_and_close(app, id, params)?;
if d.kind == "move" {
app.ui.move_options = json!({"copy": d.bool("copy"), "objects": transform_objects(d), "preview": d.bool("preview")});
// Move's Transform Patterns stays as confirmed: it is the preference it starts from.
let patterns = transform_patterns(app, d);
if patterns != app.session.prefs.transform_pattern_tiles {
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": patterns}))?;
}
}
Ok(r)
}
#[cfg(test)]
@@ -336,6 +385,75 @@ mod tests {
assert_eq!((b.x0, b.y0), (100.0, 90.0));
}
/// Move opens on the offset of the last drag or Move, and with the checkboxes it was last
/// confirmed with: Enter, Enter makes copies at the same spacing.
#[test]
fn move_opens_on_the_last_move_and_checkboxes() {
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": 10, "height": 10})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let open = |app: &mut VectorcraftApp| {
crate::toolbar::open_options(app, "selection").unwrap();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.as_ref().unwrap();
([d.f64("dx", f64::NAN), d.f64("dy", f64::NAN)], ["copy", "objects", "preview"].map(|k| d.bool(k)))
};
let lefts = |app: &VectorcraftApp| {
let kids = app.session.doc().unwrap().doc.layers[0].children().unwrap();
kids.iter().map(|n| n.geometric_bounds().unwrap().x0).collect::<Vec<_>>()
};
assert_eq!(open(&mut app), ([0.0, 0.0], [false, true, false]), "nothing moved yet");
crate::dialogs::cancel(&mut app);
// A drag: its offset, and a rotation since doesn't count.
app.session.begin_interaction("Move").unwrap();
app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 20, 5]})).unwrap();
app.session.commit_interaction().unwrap();
app.run("object.rotate", json!({"angle": 90})).unwrap();
app.run("object.rotate", json!({"angle": -90})).unwrap();
assert_eq!(open(&mut app).0, [20.0, 5.0]);
let d = app.ui.dialog.as_mut().unwrap();
d.fields.extend([("dx".to_string(), json!(30)), ("dy".to_string(), json!(0)), ("copy".to_string(), json!(true))]);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(open(&mut app), ([30.0, 0.0], [true, true, false]));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(lefts(&app), [20.0, 50.0, 80.0], "a copy at 30 pt each time");
// A drag moves the next one by its offset instead; the checkboxes stay.
app.session.begin_interaction("Move").unwrap();
app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 12]})).unwrap();
app.session.commit_interaction().unwrap();
assert_eq!(open(&mut app), ([0.0, 12.0], [true, true, false]));
crate::dialogs::cancel(&mut app);
}
/// The Move dialog's Transform Patterns is as it was last confirmed with: its OK sets General ›
/// Transform Pattern Tiles, which it starts from.
#[test]
fn move_keeps_transform_patterns_in_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": 10, "height": 10})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let open = |app: &mut VectorcraftApp| {
crate::menus::invoke(app, "object.move", json!({}));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
app.ui.dialog.as_ref().unwrap().bool("patterns")
};
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": false})).unwrap();
assert!(!open(&mut app));
app.ui.dialog.as_mut().unwrap().fields.insert("patterns".into(), json!(true));
crate::dialogs::confirm(&mut app).unwrap();
assert!(open(&mut app) && app.session.prefs.transform_pattern_tiles, "as confirmed");
crate::dialogs::cancel(&mut app);
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": false})).unwrap();
assert!(!open(&mut app), "the preference");
crate::dialogs::cancel(&mut app);
}
/// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the
/// command.
#[test]
+5 -3
View File
@@ -3299,7 +3299,8 @@ fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> {
/// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does.
fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> {
Some(match id {
"object.move" => ("move", json!({"dx": "0 pt", "dy": "0 pt"})),
// Move opens on the offset moved last and the checkboxes it was last confirmed with.
"object.move" => ("move", crate::dialogs::transform::move_fields(app)),
"object.rotate" => ("rotate", json!({"angle": 0})),
"object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})),
"object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::fields()),
@@ -3376,8 +3377,9 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
}
if let Some((kind, fields)) = menu_dialog(app, id)
&& p.as_object().is_none_or(|o| o.is_empty())
// Params first: some dialogs' fields take a look through the session (Move's last move).
if p.as_object().is_none_or(|o| o.is_empty())
&& let Some((kind, fields)) = menu_dialog(app, id)
{
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
return;
+61
View File
@@ -1171,6 +1171,67 @@ mod tests {
assert_eq!(kind(&app), None, "another tool has its own Enter");
}
/// A drag with the Selection tool, then Enter: the Move dialog opens on the drag's offset, and
/// Enter is its OK. With Copy ticked once, each Enter, Enter after that makes another copy at
/// the same spacing.
#[test]
fn enter_after_a_drag_repeats_its_offset() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 800, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let ctx = egui::Context::default();
let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
};
let lefts = |app: &VectorcraftApp| {
let kids = app.session.doc().unwrap().doc.layers[0].children().unwrap();
kids.iter().map(|n| n.geometric_bounds().unwrap().x0.round()).collect::<Vec<_>>()
};
app.select_tool("selection");
let view = app.view_info();
for (kind, x, y) in
[(PointerKind::Down, 20.0, 20.0), (PointerKind::Drag, 40.0, 20.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Up, 60.0, 20.0)]
{
app.session.pointer(&PointerEvent::new(kind, x, y), view).unwrap();
}
let [moved] = lefts(&app)[..] else { panic!("one rectangle") };
let step = moved - 10.0;
assert!(step > 0.0);
// Keys reach the canvas from the second frame on.
frame(&mut app, vec![]);
frame(&mut app, vec![enter()]);
let d = app.ui.dialog.as_mut().expect("Enter opens Move");
assert_eq!((d.kind.as_str(), d.f64("dx", f64::NAN).round(), d.f64("dy", f64::NAN)), ("move", step, 0.0));
d.fields.insert("copy".into(), json!(true));
frame(&mut app, vec![enter()]);
for _ in 0..2 {
frame(&mut app, vec![enter()]);
frame(&mut app, vec![enter()]);
}
assert!(app.ui.dialog.is_none());
assert_eq!(lefts(&app), (0..4).map(|i| moved + step * f64::from(i)).collect::<Vec<_>>());
// With Preview on too, each Enter, Enter still makes one copy, and one undo step.
frame(&mut app, vec![enter()]);
app.ui.dialog.as_mut().expect("Move").fields.insert("preview".into(), json!(true));
frame(&mut app, vec![enter()]);
let steps = app.session.active().unwrap().history.undo.len();
for _ in 0..2 {
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("preview") && d.bool("copy")) && app.session.in_interaction());
frame(&mut app, vec![enter()]);
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
}
assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 2);
assert_eq!(lefts(&app), (0..7).map(|i| moved + step * f64::from(i)).collect::<Vec<_>>());
}
/// #1661: typed `[` and `]` reach the tool that takes them: the Blob Brush's and the Eraser's
/// size (Option or AltGr brackets too), the stroke weight left as it is; other tools leave them
/// alone.
+5
View File
@@ -452,6 +452,10 @@ pub struct UiState {
/// dialog opens on them (#1152).
#[serde(default)]
pub offset_path: Value,
/// The Move dialog's checkboxes as it was last confirmed with (copy, objects, preview; null:
/// never used): it opens with them.
#[serde(default)]
pub move_options: Value,
/// The DXF Import Options chosen last (fit, scaleLineweights, center, mergeLayers; null:
/// never used).
#[serde(default)]
@@ -781,6 +785,7 @@ impl Default for UiState {
dxf_options: Value::Null,
eps_options: Value::Null,
offset_path: Value::Null,
move_options: Value::Null,
dxf_import: Value::Null,
home: None,
layers_panel: Default::default(),
+3 -1
View File
@@ -177,7 +177,9 @@ for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fie
`appearance.copyFrom` copies). For `hand` it fits the artboard in the window (`view.fitArtboard`) and for `zoom` it
shows 100% (`view.actualSize`). For `rotate`, `scale`, `reflect` and `shear` it opens the same dialog as Object ›
Transform (dialog kind = the tool id), or fails with `nothing selected`. For `selection`, `directSelection` and
`groupSelection` it opens the Move dialog (kind `move`), with the same failure. `ui.key` Enter with one of those seven
`groupSelection` it opens the Move dialog (kind `move`), with the same failure; Move opens on the offset of the last
drag or Move (`dx`, `dy`) and with the `copy`, `objects` and `preview` it was last confirmed with (its `patterns` is
the `transformPatternTiles` preference, which its OK sets). `ui.key` Enter with one of those seven
tools opens its dialog too (nothing happens without a selection), with the transform tools' `origin` at their reference
point. Gradient tool handles snap to anchors, edges and smart guides; Shift constrains them to 45° steps from the
`constrainAngle` preference.
File diff suppressed because one or more lines are too long
+4 -2
View File
File diff suppressed because one or more lines are too long