Merge pull request #4175 from Taha-Mahmoodi/fix/1732-transform-each-remembers

Fix #1732: Transform Each opens on the values it was last confirmed with and has a Copy button
This commit is contained in:
BFlat
2026-10-26 09:20:40 +07:00
committed by GitHub
7 changed files with 181 additions and 10 deletions
+14
View File
@@ -170,6 +170,9 @@ pub(crate) struct DialogSpec {
pub ok: Option<&'static str>,
/// An extra button left of Cancel that sets `discard: true` and confirms ("Don't Save").
pub discard: Option<&'static str>,
/// A button left of Cancel, as (label, field), that sets `field: true` and confirms, as OK
/// does otherwise (Transform Each's Copy).
pub alternate: Option<(&'static str, &'static str)>,
pub min_width: f32,
pub max_width: Option<f32>,
/// The body runs a live preview interaction that Cancel rolls back.
@@ -204,6 +207,7 @@ impl DialogSpec {
},
ok: Some("OK"),
discard: None,
alternate: None,
min_width: 320.0,
max_width: None,
preview: false,
@@ -374,6 +378,7 @@ pub fn button_labels() -> Vec<&'static str> {
for spec in std::iter::once(&DialogSpec::FORM).chain(DialogKind::ALL.iter().map(|k| k.spec())) {
v.extend(spec.ok);
v.extend(spec.discard);
v.extend(spec.alternate.map(|(label, _)| label));
}
v.sort_unstable();
v.dedup();
@@ -517,6 +522,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let mut ok_enabled = true;
let mut cancel = false;
let mut discard = false;
let mut alternate = false;
// Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go.
let after_panel = d.bool(AFTER_PANEL);
if after_panel && !ctx.input(|i| i.key_down(egui::Key::Enter) || i.key_pressed(egui::Key::Enter)) {
@@ -578,6 +584,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some((label, _)) = spec.alternate {
ui.add_space(8.0);
alternate = widgets::secondary_button(ui, label).clicked() && ok_enabled;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
@@ -591,6 +601,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
d.fields.insert("discard".into(), json!(true));
ok = true;
}
if alternate && let Some((_, field)) = spec.alternate {
d.fields.insert(field.into(), json!(true));
ok = true;
}
app.ui.dialog = Some(d);
if cancel {
self::cancel(app);
+146 -4
View File
@@ -4,8 +4,9 @@
//! and reflection, reference point and Random options are the Transform effect's too.
//!
//! Fields: `scaleH`, `scaleV` (%), `moveH`, `moveV` (pt), `rotate` (°), `reflectX`, `reflectY`,
//! `random`, `reference` (0..8, the 9-point grid), `copy`, `objects`, `patterns`, `corners`,
//! `strokes` (the last two default to the preferences, which OK updates) and `preview`.
//! `random`, `reference` (0..8, the 9-point grid), `copy` (set by the Copy button, which confirms),
//! `objects`, `patterns`, `corners`, `strokes` (the last two default to the preferences, which OK
//! updates) and `preview`. It opens on the values it was last confirmed with, Copy aside (#1732).
use serde_json::{Value, json};
@@ -21,10 +22,13 @@ pub const KIND: &str = "transformEach";
const CMD: &str = "object.transformEach";
/// OK transforms each object; Copy, left of Cancel as in the documented dialog, transforms a copy
/// of each ([Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html)).
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Transform Each").into(),
body,
confirm,
alternate: Some(("Copy", "copy")),
preview: true,
min_width: 360.0,
inputs: |_| &["objects", "patterns", "corners", "strokes"],
@@ -36,6 +40,24 @@ pub fn fields() -> Value {
json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4, "copy": false})
}
/// The fields kept from the last OK or Copy (`UiState::transform_each`): all but Copy, Transform
/// Patterns (the preference it starts from) and the scale options (preferences too).
const REMEMBERED: [&str; 11] = ["scaleH", "scaleV", "moveH", "moveV", "rotate", "reflectX", "reflectY", "random", "reference", "objects", "preview"];
/// The fields the menu item opens with: [`fields`], with the values it was last confirmed with.
/// A kept value of the wrong kind (a hand-edited file) is left out.
pub fn open_fields(app: &VectorcraftApp) -> Value {
let mut out = fields();
for k in REMEMBERED {
match app.ui.transform_each.get(k) {
Some(v @ Value::Number(_)) if out.get(k).is_some_and(Value::is_number) => out[k] = v.clone(),
Some(v @ Value::Bool(_)) if out.get(k).is_none_or(Value::is_boolean) => out[k] = v.clone(),
_ => {}
}
}
out
}
/// The command's parameters without the scale options.
fn params(d: &Dialog) -> Value {
let f = |k: &str, v: f64| d.f64(k, v);
@@ -114,7 +136,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
object_options(app, ui, d);
scale_options(app, ui, d);
reflect_options(ui, d);
form::check(ui, d, "copy", tl!("Copy"));
});
});
let mut p = params(d);
@@ -128,7 +149,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = params(d);
add_object_options(app, d, &mut p);
let p = with_scale_options(app, d, p)?;
form::commit_preview(app, CMD, p)
let mut kept = serde_json::Map::new();
for k in REMEMBERED {
let v = if k == "preview" { json!(d.bool("preview")) } else { p[k].clone() };
kept.insert(k.into(), v);
}
let r = form::commit_preview(app, CMD, p)?;
app.ui.transform_each = Value::Object(kept);
Ok(r)
}
#[cfg(test)]
@@ -165,4 +193,118 @@ mod tests {
let (cmd, p) = app.session.journal.last().unwrap();
assert_eq!((cmd.as_str(), &p["strokes"], &p["objects"]), (CMD, &json!(false), &json!(true)), "#1448: Transform Objects goes along");
}
/// An app with a selected 50 × 50 square at (20, 20).
fn square_app() -> (VectorcraftApp, NodeId) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
(app, NodeId(id))
}
fn open(app: &mut VectorcraftApp) -> &mut Dialog {
crate::menus::invoke(app, CMD, json!({}));
let d = app.ui.dialog.as_mut().expect("Transform Each is open");
assert_eq!(d.kind, KIND);
d
}
#[test]
fn a_fresh_app_opens_on_the_defaults() {
let (mut app, _) = square_app();
let d = open(&mut app);
assert_eq!(Value::Object(d.fields.clone()), fields());
}
/// #1732: the dialog opens on the values it was last confirmed with (as Move and Offset Path
/// do), but never on Copy.
#[test]
fn it_reopens_on_the_values_confirmed_last() {
let (mut app, _) = square_app();
let d = open(&mut app);
for (k, v) in [
("scaleH", json!(150)),
("moveH", json!(20)),
("rotate", json!(15)),
("reflectX", json!(true)),
("random", json!(true)),
("reference", json!(0)),
("objects", json!(false)),
("patterns", json!(true)),
("copy", json!(true)),
] {
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let d = open(&mut app);
for (k, v) in [
("scaleH", json!(150.0)),
("scaleV", json!(100.0)),
("moveH", json!(20.0)),
("rotate", json!(15.0)),
("reflectX", json!(true)),
("reflectY", json!(false)),
("random", json!(true)),
("reference", json!(0)),
("objects", json!(false)),
("preview", json!(false)),
("copy", json!(false)),
] {
assert_eq!(d.fields.get(k), Some(&v), "{k}");
}
// Cancel keeps nothing new.
d.fields.insert("scaleH".into(), json!(300));
super::super::cancel(&mut app);
assert_eq!(open(&mut app).fields["scaleH"], json!(150.0));
// The remembered values go with the UI state; one saved before they existed loads, and
// opens on the defaults.
app.ui.dialog = None;
app.ui = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
assert_eq!(open(&mut app).fields["scaleH"], json!(150.0));
app.ui = serde_json::from_value(json!({"move_options": {"copy": true}})).unwrap();
assert_eq!(Value::Object(open(&mut app).fields.clone()), fields());
}
/// #1732: the Copy button transforms a copy of each object, in one undo step, and Transform
/// Again makes a further copy; there is no Copy checkbox.
#[test]
fn the_copy_button_transforms_a_copy_of_each_object() {
use egui_kittest::kittest::Queryable;
let (mut app, id) = square_app();
let d = open(&mut app);
d.fields.insert("moveH".into(), json!(100));
let undo = app.session.doc().unwrap().history.undo.len();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
super::super::show(app, ui.ctx());
},
app,
);
h.run_steps(4);
assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Copy").is_none(), "no Copy checkbox");
h.get_by_role_and_label(egui::accesskit::Role::Button, "Copy").click();
h.run_steps(3);
let app = h.state_mut();
assert!(app.ui.dialog.is_none(), "Copy closes the dialog");
let xs = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
let mut xs: Vec<f64> = st.doc.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap().x0.round()).collect();
xs.sort_by(f64::total_cmp);
xs
};
assert_eq!(xs(app), vec![20.0, 120.0], "the square stays, its copy moved 100 pt");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert!(app.session.doc().unwrap().doc.node(id).is_some());
assert_eq!(open(app).fields["copy"], json!(false), "Copy isn't remembered");
app.ui.dialog = None;
app.run("object.transformAgain", json!({})).unwrap();
assert_eq!(xs(app), vec![20.0, 120.0, 220.0], "Transform Again copies once more");
h.ctx.forget_all_images();
}
}
+3 -2
View File
@@ -836,7 +836,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.menuDialog",
"Menu Dialog",
"",
"{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)",
"{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)",
),
(
"ui.widthPointEdit",
@@ -3630,7 +3630,8 @@ fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)>
"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()),
// Transform Each opens on the values it was last confirmed with (#1732).
"object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::open_fields(app)),
"object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})),
"object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})),
"path.average" => ("average", json!({"axis": "both"})),
+6
View File
@@ -540,6 +540,11 @@ pub struct UiState {
/// never used): it opens with them.
#[serde(default)]
pub move_options: Value,
/// The Transform Each fields it was last confirmed with (scale, move, rotate, reflect, random,
/// reference point, Transform Objects and Preview, not Copy; null: never used): it opens on
/// them (#1732).
#[serde(default)]
pub transform_each: Value,
/// The DXF Import Options chosen last (fit, scaleLineweights, center, mergeLayers; null:
/// never used).
#[serde(default)]
@@ -861,6 +866,7 @@ impl Default for UiState {
offset_path: Value::Null,
simplify: Value::Null,
move_options: Value::Null,
transform_each: Value::Null,
dxf_import: Value::Null,
home: None,
trace_hold_source: None,
+6 -2
View File
@@ -1,6 +1,6 @@
# Control protocol
> **Last updated:** 2026-10-25 · **Change:** panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
> **Last updated:** 2026-10-25 · **Change:** Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726).
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number
from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits
@@ -426,7 +426,10 @@ Scale options: `ui.menuDialog {command}` opens the dialog a command's menu item
have the fields `corners` (Scale Corners) and `strokes` (Scale Strokes & Effects). They start from the preferences
`scaleCorners` and `scaleStrokes`, and `ui.dialog.confirm` keeps them as the preferences (`prefs.set`) before running
`object.scale` / `object.transformEach` with them. The Transform panel's checkboxes and flyout item and the Properties
panel's checkbox set the same preferences through `prefs.set`.
panel's checkbox set the same preferences through `prefs.set`. Transform Each opens on the values it was last
confirmed with (scale, move, rotate, reflect, random, reference, `objects` and `preview`; never `copy`), kept with the
UI state (#1732). Its Copy button, left of Cancel, sets `copy: true` and confirms; `ui.dialog.set {field: "copy",
value: true}` then `ui.dialog.confirm` does the same.
Use Preview Bounds: the Align panel flyout's Use Preview Bounds item shows and toggles the `usePreviewBounds`
preference (`prefs.set`); while it is on, the bounding box and the Transform panel, Properties and Control bar
@@ -929,6 +932,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again.
|---|---|---|
| 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. |
| 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. |
| 2026-10-25 | minor | Documented that Transform Each opens on the values it was last confirmed with and that its Copy button confirms with `copy: true` (#1732). |
| 2026-10-23 | minor | Documented the symbol library panel commands (`window.symbolLibrary`, `.other`, `ui.saveSymbolLibrary`, `window.userSymbolLibrary1`…`10`). |
| 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). |
| 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). |
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long