mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user