From 77a86288e7c3b125ea25dc9c62f47ab49e11bf11 Mon Sep 17 00:00:00 2001 From: FractalEncrypt <59615752+FractalEncrypt@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:37:28 +0700 Subject: [PATCH] Transform Each: Relative and Absolute scaling, the scale link, and the documented panel order Squashed from #5695 onto main. --- crates/doc/src/document_guides.rs | 10 +- crates/doc/src/orient.rs | 14 +- crates/engine/src/cmd/menucmds.rs | 97 +++- crates/engine/src/tests/menucmds.rs | 64 ++- crates/ui-egui/src/dialogs.rs | 17 +- crates/ui-egui/src/dialogs/form.rs | 45 +- crates/ui-egui/src/dialogs/tests.rs | 406 +++++++++++++++- crates/ui-egui/src/dialogs/transform.rs | 12 +- crates/ui-egui/src/dialogs/transform_each.rs | 459 +++++++++++++++++-- crates/ui-egui/src/widgets.rs | 6 +- crates/ui-egui/src/widgets/numeric.rs | 30 +- docs/control-protocol.md | 12 +- docs/mcp.md | 15 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 7 +- 15 files changed, 1091 insertions(+), 110 deletions(-) diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 43e1c4c92..8bebc5e4e 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -6,9 +6,15 @@ impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol /// instances measured by their art ([`Node::geometric_bounds_with`]). pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { - let symbols = self.symbol_boxes(); + self.bounds_of_with(ids, visual, &self.symbol_boxes()) + } + + /// [`bounds_of`](Document::bounds_of) measuring symbol instances from `symbols`, a cache shared + /// across calls: measuring many instances one by one otherwise measures their art again for + /// each. + pub fn bounds_of_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option { ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { - vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) + vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }) }) } diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 114a15290..e524d6ca3 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -125,9 +125,19 @@ impl Document { if angle == 0.0 { return self.bounds_of(ids, visual).map(OrientedBox::aligned); } + self.oriented_bounds_with(ids, visual, &self.symbol_boxes()) + } + + /// [`oriented_bounds`](Document::oriented_bounds) measuring symbol instances from `symbols`, + /// a cache shared across calls: measuring many instances one by one otherwise measures their + /// art again for each. + pub fn oriented_bounds_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option { + let angle = self.bbox_angle(ids); + if angle == 0.0 { + return self.bounds_of_with(ids, visual, symbols).map(OrientedBox::aligned); + } let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse(); - let symbols = self.symbol_boxes(); - let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?; + let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(symbols, to_local, visual)))?; Some(OrientedBox { angle, rect }) } } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index e608bb43c..95f8c3e8f 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Transform Each…", ["Object", "Transform"], Some("Cmd+Alt+Shift+D"), - "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip left-right, as Reflect X in the Transform Each dialog), reflectY?: bool (flip top-bottom, as Reflect Y), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}", + "{scaleH?: % (100), scaleV?: % (100), relative?: bool (true: scaleH and scaleV are percentages of each object's size, as the dialog's Relative; false: Absolute, scaleH and scaleV are the width and height in pt each object becomes, measured on its own bounds, and a side left out keeps its size), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip left-right, as Reflect X in the Transform Each dialog), reflectY?: bool (flip top-bottom, as Reflect Y), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about the reference point of its own box, along its own axes → {ids}", has_selection, transform_each ), @@ -454,6 +454,30 @@ fn about(o: Point, a: Affine) -> Affine { Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) } +/// The largest width or height (pt) an Absolute Transform Each takes, so coordinates stay finite. +const MAX_ABSOLUTE: f64 = 1.0e7; + +/// The box a symbol instance or any other object has of its own, rotated with it, as the Transform +/// panel and the Control bar show it: Absolute Transform Each measures its targets on this, with +/// `symbols` shared so many instances of one symbol measure its art once. +fn own_box(doc: &vectorcraft_doc::Document, id: NodeId, symbols: &vectorcraft_doc::SymbolBoxes) -> Option { + doc.oriented_bounds_with(&[id], false, symbols).map(|b| b.rect) +} + +/// Whether Absolute scaling by `target` leaves a side of `current` as it is, to within a millionth +/// of the size: a square turned 45° measures 100.00000000000001 in its own box, and the size it +/// shows must not scale it. +fn keeps_size(target: f64, current: f64) -> bool { + (scale_factor(target, current) - 1.0).abs() <= 1.0e-9 +} + +/// The factor that takes `current` to `target` (Absolute Transform Each). A side of 0 (a line) +/// keeps its factor 1, since no factor gives it another size, and the factor is bounded as the +/// moves are, so a hairline side can't throw coordinates past what a document can hold. +fn scale_factor(target: f64, current: f64) -> f64 { + if current.abs() > 1.0e-9 { (target / current).clamp(-1.0e7, 1.0e7) } else { 1.0 } +} + fn transform_each(s: &mut Session, p: &Value) -> Result { transform_each_as(s, p, "Transform Each") } @@ -470,9 +494,17 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { let roots = selected_roots(s)?; check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?; + // Relative (the default) scales each object by a percentage of its own size; Absolute + // (`relative: false`, the dialog's Absolute) takes each object to the width and height given, + // a side left out staying as it is. + let relative = switch_or(p, "relative", true, "object.transformEach")?; // Bounded so coordinates stay finite (and the document stays serializable). - let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0); - let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0); + let given = |key: &str| p.get(key).filter(|v| !v.is_null()).and_then(Value::as_f64).map(|v| v.clamp(-MAX_ABSOLUTE, MAX_ABSOLUTE)); + let (sh, sv) = if relative { + (Some(f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0)), Some(f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0))) + } else { + (given("scaleH"), given("scaleV")) + }; let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7); let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7); let rot = f64_or(p, "rotate", 0.0); @@ -483,16 +515,26 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { let copy = switch_or(p, "copy", false, "object.transformEach")?; check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?; let objects = super::object::transforms_objects(p); - if sh == 0.0 || sv == 0.0 { + if sh == Some(0.0) || sv == Some(0.0) { return Err(bad("object.transformEach", "scale must be non-zero")); } let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1)); - let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() }; + let mut sc = if !relative || sh != Some(100.0) || sv != Some(100.0) { super::object::scaling(s, p) } else { Default::default() }; sc.patterns = super::object::transform_patterns(s, p, &roots)?; - // With no object to transform, or without a copy when both scales are 100% and there is no - // move, rotation or reflection (with or without Random), the document and its undo history - // stay as they are. - if roots.is_empty() || (!copy && sh == 100.0 && sv == 100.0 && mh == 0.0 && mv == 0.0 && rot == 0.0 && !rx && !ry) { + // With no object to transform, or without a copy when nothing would change (both scales 100% + // or, with Absolute, the size each object already has, and no move, rotation or reflection, + // with or without Random), the document and its undo history stay as they are. + let scales_change = if relative { + sh != Some(100.0) || sv != Some(100.0) + } else { + let doc = &s.doc()?.doc; + let symbols = doc.symbol_boxes(); + roots.iter().any(|id| { + own_box(doc, *id, &symbols) + .is_some_and(|b| sh.is_some_and(|v| !keeps_size(v, b.width())) || sv.is_some_and(|v| !keeps_size(v, b.height()))) + }) + }; + if roots.is_empty() || (!copy && !scales_change && mh == 0.0 && mv == 0.0 && rot == 0.0 && !rx && !ry) { return Ok(ids_json(&roots)); } let ids = s.edit(label, |d, sel| { @@ -500,22 +542,39 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { // Measure each object's geometric bounds first, with symbol instances measured by their art. let symbols = d.symbol_boxes(); let boxes: Vec> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect(); - for (id, b) in targets.iter().zip(boxes) { + let own: Vec> = targets.iter().map(|id| own_box(d, *id, &symbols)).collect(); + for ((id, b), own) in targets.iter().zip(boxes).zip(own) { let Some(b) = b else { continue }; - let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot); + let own = own.unwrap_or(b); + // Relative scales by a percentage of the object's size; Absolute takes it to the size + // given, measured on the object's own box, rotated with it, as the dialog shows it (so + // objects of different sizes come out alike, and a rotated shape keeps its size). + let (own_w, own_h) = (own.width(), own.height()); + let (mut k_sh, mut k_sv) = ( + sh.map(|v| if relative { v / 100.0 } else { scale_factor(v, own_w) }), + sv.map(|v| if relative { v / 100.0 } else { scale_factor(v, own_h) }), + ); + let (mut k_mh, mut k_mv, mut k_rot) = (mh, mv, rot); if random { - k_sh = 100.0 + (sh - 100.0) * rng.next(); - k_sv = 100.0 + (sv - 100.0) * rng.next(); - k_mh = mh * (rng.next() * 2.0 - 1.0); - k_mv = mv * (rng.next() * 2.0 - 1.0); - k_rot = rot * (rng.next() * 2.0 - 1.0); + // Five draws whatever the scales are, so a seed gives the moves and the rotation + // the same values whether a side is scaled or not. + let [r_sh, r_sv, r_mh, r_mv, r_rot] = [rng.next(), rng.next(), rng.next(), rng.next(), rng.next()]; + k_sh = k_sh.map(|v| 1.0 + (v - 1.0) * r_sh); + k_sv = k_sv.map(|v| 1.0 + (v - 1.0) * r_sv); + k_mh = mh * (r_mh * 2.0 - 1.0); + k_mv = mv * (r_mv * 2.0 - 1.0); + k_rot = rot * (r_rot * 2.0 - 1.0); } - let o = vectorcraft_geom::reference_point(b, refi); + // The object's own box, turned with it, as the Transform panel and the dialog show it: + // the scales act along its axes, about that box's reference point, so a turned square + // keeps its shape and the size the dialog shows is the one it takes. + let frame = vectorcraft_doc::OrientedBox { angle: d.bbox_angle(&[*id]), rect: own }; + let o = frame.reference_point(refi); // Reflect X / Reflect Y match Object › Transform › Transform Each and Effect › Distort › Transform: // Reflect X flips left-right (negate x), Reflect Y flips top-bottom (negate y). let refl = Affine::scale_non_uniform(if rx { -1.0 } else { 1.0 }, if ry { -1.0 } else { 1.0 }); - let m = Affine::translate((k_mh, k_mv)) - * about(o, Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh / 100.0, k_sv / 100.0)); + let scale = Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh.unwrap_or(1.0), k_sv.unwrap_or(1.0)); + let m = Affine::translate((k_mh, k_mv)) * about(o, frame.conjugate(scale)); if let Some(n) = d.node_mut(*id) { super::object::transform_node(n, m, sc, objects); } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index d14502659..450baabc1 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1070,6 +1070,61 @@ fn transform_each_scales_each_about_its_own_center() { assert_eq!(undo_len(&s), n + 1); } +/// The dialog's Absolute (`relative: false`): `scaleH` and `scaleV` are the width and height each +/// object becomes, measured on its own bounds, so objects of different sizes come out alike about +/// their own reference points. A side left out keeps its size, a side of 0 (a line) stays 0, and +/// Absolute already at the size the objects have changes nothing and records no undo step. +#[test] +fn transform_each_absolute_takes_each_object_to_the_size_given() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + let b = rect(&mut s, 200.0, 0.0, 40.0, 80.0); + sel(&mut s, &[a, b]); + let n = undo_len(&s); + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 60, "scaleV": 20})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(20.0, 15.0, 80.0, 35.0)); + assert_eq!(bounds(&s, b), Rect::new(190.0, 30.0, 250.0, 50.0)); + assert_eq!(undo_len(&s), n + 1); + // A side left out keeps its size, and the reference point still holds the new size in place. + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 30, "reference": 0})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(20.0, 15.0, 50.0, 35.0), Rect::new(190.0, 30.0, 220.0, 50.0))); + let before = (undo_len(&s), bounds(&s, a), bounds(&s, b)); + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 30, "scaleV": 20})).unwrap(); + assert_eq!((undo_len(&s), bounds(&s, a), bounds(&s, b)), before, "already the size given"); + // A line has no height to take: its side stays as it is. + let line = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 10}, {"x": 100, "y": 10}], "closed": false})).unwrap()); + sel(&mut s, &[line]); + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 50, "scaleV": 80})).unwrap(); + assert_eq!(bounds(&s, line), Rect::new(25.0, 10.0, 75.0, 10.0)); + // A scale of 0 is refused in either mode, as it collapses the object. + for p in [json!({"relative": false, "scaleH": 0}), json!({"scaleH": 0})] { + let r = s.execute("object.transformEach", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == "scale must be non-zero"), "{p}: {r:?}"); + } +} + +/// Absolute measures each object's own box, rotated with it, as the Transform panel shows it: a +/// square turned 45° keeps its size when the size it shows is applied, and a width applied is the +/// square's own width, not its bounding box's. +#[test] +fn transform_each_absolute_measures_a_rotated_object_its_own_way() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.rotate", &json!({"angle": 45})).unwrap(); + let own = |s: &Session| s.doc().unwrap().doc.oriented_bounds(&[a], false).unwrap().rect; + let bbox = |s: &Session| bounds(s, a); + assert!((own(&s).width() - 100.0).abs() < 1e-6 && (bbox(&s).width() - 141.421).abs() < 0.01, "turned 45°"); + // The size it shows, applied, changes nothing and records no undo step. + let before = (undo_len(&s), bbox(&s)); + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 100, "scaleV": 100})).unwrap(); + assert_eq!((undo_len(&s), bbox(&s)), before, "already the size it shows"); + // A width is the square's own width. + s.execute("object.transformEach", &json!({"relative": false, "scaleH": 200})).unwrap(); + assert!((own(&s).width() - 200.0).abs() < 1e-6, "200 wide: {:?}", own(&s)); + assert!((own(&s).height() - 100.0).abs() < 1e-6, "its height kept: {:?}", own(&s)); +} + #[test] fn transform_each_move_reference_and_copy() { let mut s = session(); @@ -1144,9 +1199,10 @@ fn transform_each_refuses_a_number_given_as_text() { assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0)); } -/// Transform Each refuses Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale -/// Corners, Transform Objects or Transform Patterns given as text or as another value that is not -/// true or false, and the object stays as it was and is not copied; true, false and null still work. +/// Transform Each refuses Reflect X, Reflect Y, Random, Copy, Relative, Scale Strokes & Effects, +/// Scale Corners, Transform Objects or Transform Patterns given as text or as another value that is +/// not true or false, and the object stays as it was and is not copied; true, false and null still +/// work. #[test] fn transform_each_refuses_a_boolean_given_as_text() { let mut s = session(); @@ -1160,6 +1216,8 @@ fn transform_each_refuses_a_boolean_given_as_text() { ("random", json!({"moveH": 10, "random": "false"})), ("copy", json!({"copy": "true"})), ("copy", json!({"moveH": 10, "copy": [true]})), + ("relative", json!({"relative": "false"})), + ("relative", json!({"relative": 0})), ("strokes", json!({"scaleH": 50, "scaleV": 50, "strokes": "false"})), ("corners", json!({"scaleH": 50, "corners": 1})), ("objects", json!({"moveH": 10, "objects": "false"})), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 099d6db58..3373ebf9c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -211,6 +211,9 @@ pub(crate) struct DialogSpec { pub ok_label: Option &'static str>, /// Can OK (or Enter) apply the dialog as it is? None: always. The body says why not. pub ok_enabled: Option bool>, + /// What a dialog draws at the left end of its button row, level with the buttons, instead of + /// in its body (Transform Each's Preview checkbox). + pub footer_left: Option, } impl DialogSpec { @@ -240,6 +243,7 @@ impl DialogSpec { passes: |_, _| false, ok_label: None, ok_enabled: None, + footer_left: None, }; /// A dialog that draws its own window and confirms through its module. @@ -504,10 +508,14 @@ pub(crate) fn parent(d: &Dialog) -> Option { } /// `ui.dialog.set` changed `field` of the open dialog from `old`: a choice that sets other fields -/// sets them as choosing it in the dialog does (Export for Screens' `exportPreset`), and a change +/// sets them as choosing it in the dialog does (Export for Screens' `exportPreset`), a change /// to Export for Screens' sub-folder controls ends an applied preset's item folders, as it does in -/// the dialog. +/// the dialog, and Transform Each's Relative / Absolute keeps the Scale fields' size. pub(crate) fn field_set(app: &mut VectorcraftApp, field: &str, old: Option<&Value>) -> Result<(), String> { + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == transform_each::KIND) { + transform_each::field_set(app, field, old); + return Ok(()); + } let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == export_for_screens::KIND) else { return Ok(()) }; match field { "exportPreset" => export_for_screens::preset_set(app), @@ -719,6 +727,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.add_space(28.0); discard = widgets::secondary_button(ui, label).clicked(); } + // The row runs right to left: a left-to-right layout in the room left of the buttons + // puts its control at the row's left end. + if let Some(left) = spec.footer_left { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| left(ui, &mut d)); + } }); }); let rejected = ctx.data_mut(|d| d.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 004a96490..1d8d30d8e 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -64,6 +64,24 @@ pub(crate) fn length_within( true } +/// [`length_within`] for a value the selected objects may not share: with no value the field reads +/// `empty` ("Mixed"), and typing a distance sets it. +pub(crate) fn length_mixed( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + unit: Unit, + width: f32, + range: Option>, + empty: &str, +) -> bool { + let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0)); + let bounds = range.unwrap_or(crate::widgets::ANY); + let Some(v) = crate::widgets::num_field_within_empty(ui, ("dlg-len", key), value, empty, unit, width, bounds) else { return false }; + d.fields.insert(key.into(), json!(v)); + true +} + /// What [`length`] shows for `d.fields[key]`, as the screen-reader value of a rail over `range` /// (in points) beside it: the number in `unit` and the field's text. `None` while the field is /// blank. @@ -883,15 +901,30 @@ pub(super) fn preview_result( cmd: &str, params: Value, ) -> Option { - const LAST: &str = PREVIEWED; ui.add_space(8.0); - let mut on = d.bool("preview"); + preview_check(ui, d); + preview_or_rollback(app, d, label, cmd, params) +} + +/// The Preview checkbox (field `preview`), for a dialog that draws it in its button row +/// (`DialogSpec::footer_left`) instead of through [`preview`]. +pub(crate) fn preview_check(ui: &mut egui::Ui, d: &mut Dialog) { + let on = d.bool("preview"); if crate::widgets::check(ui, tl!("Preview"), on, true) { - on = !on; - d.fields.insert("preview".into(), json!(on)); + d.fields.insert("preview".into(), json!(!on)); } - if !on { - if d.fields.remove(LAST).is_some() { +} + +/// [`preview`] without its checkbox, which the dialog draws itself ([`preview_check`]): the preview +/// runs while the field is on and rolls back when it is off. +pub(crate) fn preview_without_check(app: &mut crate::VectorcraftApp, d: &mut Dialog, label: &str, cmd: &str, params: Value) { + let _ = preview_or_rollback(app, d, label, cmd, params); +} + +/// [`preview_now`] while Preview is on; off, it rolls the preview back. +fn preview_or_rollback(app: &mut crate::VectorcraftApp, d: &mut Dialog, label: &str, cmd: &str, params: Value) -> Option { + if !d.bool("preview") { + if d.fields.remove(PREVIEWED).is_some() { let _ = app.session.cancel_interaction(); } return None; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index c8b0bf6e2..833366b14 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -724,14 +724,16 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { assert!(app.ui.dialog.is_none()); let after = bounds(&app); assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); - // Transform Each, then the Transform effect: Scale › Horizontal, a plain number, comes first. - let before = bounds(&app); + // Transform Each, then the Transform effect: Scale › Horizontal, a plain number, comes first, + // and it scales the object's own width, turned with it (this one is on its side). + let own = |app: &VectorcraftApp| app.session.doc().unwrap().doc.oriented_bounds(&[vectorcraft_doc::NodeId(id)], false).unwrap().rect; + let before = own(&app); crate::menus::invoke(&mut app, "object.transformEach", json!({})); frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text("50".into())]); frame(&mut app, vec![enter()]); assert!(app.ui.dialog.is_none()); - let after = bounds(&app); + let after = own(&app); assert!((after.width() - before.width() / 2.0).abs() < 1e-6 && (after.height() - before.height()).abs() < 1e-6, "{before:?} → {after:?}"); app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap(); frame(&mut app, vec![]); @@ -1047,6 +1049,399 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga /// Drop Shadow's dialog has Color and Darkness as radio buttons, each with its field; a click on /// Darkness previews the shadow with it and OK applies it. +/// The Transform Each panel lists its sections in the documented order: Scale (with the Relative +/// and Absolute radio buttons under its sliders), Move, Rotate, then the Options in two columns +/// (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, +/// Reflect Y, Random) and the Reference Point below them, above Preview. +#[test] +fn transform_each_lists_scale_move_rotate_options_and_the_reference_point() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("no {s:?} in {text}")); + // Scale, its sliders and the Relative / Absolute pair, then Move and Rotate. + let [scale, horizontal, vertical, relative, absolute, mv, rotate, options] = + ["Scale", "Horizontal:", "Vertical:", "Relative", "Absolute", "Move", "Rotate", "Options"].map(at); + assert!( + scale < horizontal && horizontal < vertical && vertical < relative && relative < absolute && absolute < mv && mv < rotate && rotate < options, + "{text}" + ); + // Options: the object and scale options, then the reflections and Random, then the reference + // point. + let [objects, patterns, strokes, corners, reflect_x, reflect_y, random, reference] = [ + "Transform Objects", + "Transform Patterns", + "Scale Strokes & Effects", + "Scale Corners", + "Reflect X", + "Reflect Y", + "Random", + "Reference Point", + ] + .map(at); + assert!( + options < objects + && objects < patterns + && patterns < strokes + && strokes < corners + && corners < reflect_x + && reflect_x < reflect_y + && reflect_y < random + && random < reference + && reference < at("Preview"), + "{text}" + ); +} + +/// The Scale section's Relative and Absolute radio buttons: Absolute shows the width and height the +/// selected objects share, OK sends `relative: false` with the sizes typed, and Relative keeps its +/// own percentages. +#[test] +fn transform_each_switches_between_relative_and_absolute_scale() { + use egui::accesskit::Role; + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let mode = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Transform Each is open").fields["relative"].clone(); + let scale = |app: &VectorcraftApp, h: &str, v: &str| { + let d = app.ui.dialog.as_ref().expect("Transform Each is open"); + (d.f64(h, f64::NAN), d.f64(v, f64::NAN)) + }; + assert_eq!(mode(&app), json!(true), "Relative at first"); + assert_eq!(scale(&app, "scaleH", "scaleV"), (100.0, 100.0)); + let click = |f: &mut Frames, app: &mut VectorcraftApp, label: &str| { + let at = f.control(app, label, Role::RadioButton); + f.press(app, at, true); + f.press(app, at, false); + f.run(app, vec![]); + }; + click(&mut f, &mut app, "Absolute"); + assert_eq!((mode(&app), scale(&app, "absH", "absV")), (json!(false), (60.0, 20.0)), "the size the rectangle has"); + // Exact sizes: 40 × 30 pt about the centre, as Absolute takes them. + app.ui.dialog.as_mut().unwrap().fields.extend([("absH".to_string(), json!(40)), ("absV".to_string(), json!(30))]); + f.run(&mut app, vec![]); + confirm(&mut app).unwrap(); + let b = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (20.0, 5.0, 60.0, 35.0)); + let (cmd, p) = app.session.journal.last().unwrap(); + assert_eq!((cmd.as_str(), &p["relative"], &p["scaleH"], &p["scaleV"]), ("object.transformEach", &json!(false), &json!(40.0), &json!(30.0))); + // The dialog opens on Absolute again, with the size the objects have now; Relative keeps its own + // percentages. + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + f.run(&mut app, vec![]); + assert_eq!((mode(&app), scale(&app, "absH", "absV")), (json!(false), (40.0, 30.0)), "Absolute is kept, at the size they have"); + click(&mut f, &mut app, "Relative"); + assert_eq!((mode(&app), scale(&app, "scaleH", "scaleV")), (json!(true), (100.0, 100.0)), "the percentages are the dialog's own"); +} + +/// Switching to Absolute with Preview on shows the size the objects have without the preview, not +/// the size it has scaled them to. +#[test] +fn transform_each_switching_to_absolute_ignores_the_preview() { + use egui::accesskit::Role; + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + app.ui.dialog.as_mut().unwrap().fields.extend([("scaleH".to_string(), json!(50)), ("preview".to_string(), json!(true))]); + f.run(&mut app, vec![]); + let width = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap().width(); + assert_eq!(width(&app), 30.0, "the preview scaled it"); + let at = f.control(&mut app, "Absolute", Role::RadioButton); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + f.run(&mut app, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["relative"].clone(), d.f64("absH", 0.0), d.f64("absV", 0.0)), (json!(false), 60.0, 20.0), "the size before the preview"); +} + +/// `ui.dialog.set` switching to Absolute shows the size the objects share, and the Absolute sizes a +/// request sets after the mode stay as set. +#[test] +fn transform_each_control_set_of_relative_keeps_the_sizes_after_it() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let set = |app: &mut VectorcraftApp, field: &str, value: serde_json::Value| { + let old = app.ui.dialog.as_ref().and_then(|d| d.fields.get(field).cloned()); + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), field.into(), value); + field_set(app, field, old.as_ref()).unwrap(); + }; + set(&mut app, "relative", json!(false)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (60.0, 20.0), "the size the rectangle has"); + for (field, value) in [("absH", json!(40)), ("absV", json!(30))] { + set(&mut app, field, value); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["relative"].clone(), d.f64("absH", 0.0), d.f64("absV", 0.0)), (json!(false), 40.0, 30.0)); +} + +/// With the link on, switching to Absolute keeps the proportions the fields then show, and a scale +/// set over the control channel follows the link as a typed one does. +#[test] +fn transform_each_scale_link_follows_a_mode_switch_and_control_sets() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let set = |app: &mut VectorcraftApp, field: &str, value: serde_json::Value| { + let old = app.ui.dialog.as_ref().and_then(|d| d.fields.get(field).cloned()); + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), field.into(), value); + field_set(app, field, old.as_ref()).unwrap(); + }; + set(&mut app, "__constrain", json!(true)); + set(&mut app, "relative", json!(false)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (60.0, 20.0), "the size the rectangle has"); + // The link keeps the 3 : 1 the fields show: 30 wide is 10 high. + set(&mut app, "absH", json!(30)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (30.0, 10.0)); +} + +/// The Scale link: while it is on, changing one scale sets the other, so the two keep the +/// proportions they had when it was turned on; it is kept with the dialog's other values. +#[test] +fn transform_each_scale_link_keeps_the_proportions() { + use egui::accesskit::Role; + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let set = |app: &mut VectorcraftApp, k: &str, v: serde_json::Value| { + app.ui.dialog.as_mut().expect("Transform Each is open").fields.insert(k.into(), v); + }; + let scale = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().expect("Transform Each is open"); + (d.f64("scaleH", f64::NAN), d.f64("scaleV", f64::NAN)) + }; + set(&mut app, "scaleH", json!(100)); + set(&mut app, "scaleV", json!(200)); + let at = f.control(&mut app, "Constrain Width and Height Proportions", Role::Button); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + f.run(&mut app, vec![]); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain")), "the link is on"); + // A quarter of the way along the Horizontal slider: Vertical follows at twice it. + let slider = f.control(&mut app, "Horizontal:", Role::Slider); + let at = slider + egui::vec2(-form::SLIDER_WIDTH / 4.0, 0.0); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + f.run(&mut app, vec![]); + let (h, v) = scale(&app); + assert!(h < 100.0, "the slider moved: {h}"); + assert!((v - 2.0 * h).abs() < 1e-9, "{h} × 2 is {v}"); + // The link is kept with the dialog's other values, and it stays off when it was off. + confirm(&mut app).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain"))); + let mut d = app.ui.dialog.clone().unwrap(); + d.fields.insert("__constrain".into(), json!(false)); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + assert!(app.ui.dialog.as_ref().is_some_and(|d| !d.bool("__constrain"))); +} + +/// Transform Each's Preview checkbox sits at the left of the button row, level with Copy, Cancel +/// and OK, and turning it on previews the transform. +#[test] +fn transform_each_preview_is_level_with_the_buttons() { + use crate::widgets::accessibility_tests::tree; + use egui::accesskit::Role; + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let shown = tree(|ui| show(&mut app, ui.ctx())); + let centre_y = |label: &str| { + let node = shown.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")); + let b = node.bounds().expect("bounds"); + (b.y0 + b.y1) / 2.0 + }; + let preview = centre_y("Preview"); + for button in ["Copy", "Cancel", "OK"] { + assert!((centre_y(button) - preview).abs() <= 1.0, "{button} is not level with Preview: {} vs {preview}", centre_y(button)); + } + // The checkbox in the footer still runs the preview. + let mut f = Frames::new(); + let at = f.control(&mut app, "Preview", Role::CheckBox); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + f.run(&mut app, vec![]); + f.run(&mut app, vec![]); + assert!(app.session.in_interaction(), "Preview starts the preview"); +} + +/// Absolute reads the size the selected objects share, not the bounds of the selection: two +/// rectangles of the same size show it, and a size typed makes every object that size. +#[test] +fn transform_each_absolute_shows_the_size_the_objects_share() { + let mut app = app(); + for x in [10, 120] { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 60, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), "relative".into(), json!(false)); + field_set(&mut app, "relative", Some(&json!(true))).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (60.0, 20.0), "not the 170 pt the two together span"); +} + +/// Each object is measured by its own box, as the Transform panel and the Control bar show it: +/// rotated circles of one size read as that size, though their axis-aligned bounds differ by a +/// hair (a rotated Bezier circle's bounding box runs slightly larger than the circle). +#[test] +fn transform_each_absolute_measures_each_object_its_own_way() { + let mut app = app(); + app.run("document.setUnits", json!({"units": "millimeters"})).unwrap(); + let size = 11.8 * 72.0 / 25.4; + for (x, angle) in [(10.0, 0.0), (100.0, 30.0)] { + let id = app.run("shape.ellipse", json!({"x": x, "y": 10, "width": size, "height": size})).unwrap()["id"].as_u64().unwrap(); + if angle != 0.0 { + app.run("select.none", json!({})).unwrap(); + app.run("select.add", json!({"ids": [id]})).unwrap(); + app.run("object.rotate", json!({"angle": angle})).unwrap(); + } + } + app.run("select.all", json!({})).unwrap(); + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (size, size), "11.8 mm each"); +} + +/// Objects of different sizes read "Mixed", and a size typed sets every one of them to it. +#[test] +fn transform_each_absolute_reads_mixed_and_sets_every_object() { + let mut app = app(); + let small = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let large = app.run("shape.rectangle", json!({"x": 120, "y": 10, "width": 40, "height": 80})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + // The dialog opens in Absolute, as it was last confirmed. + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields.get("absH"), d.fields.get("absV")), (None, None), "no size they share"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert_eq!(text.lines().filter(|l| *l == "Mixed").count(), 2, "both fields read Mixed: {text}"); + // The first field takes the keyboard as the dialog opens: typing a width sets every object to + // it, and the side left Mixed keeps its size. + f.run(&mut app, vec![]); + f.run(&mut app, vec![egui::Event::Text("50".into())]); + f.key(&mut app, egui::Key::Enter, egui::Modifiers::NONE); + assert!(app.ui.dialog.is_none(), "Enter applies it"); + let size = |id: u64| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!((size(small).width(), size(small).height()), (50.0, 20.0)); + assert_eq!((size(large).width(), size(large).height()), (50.0, 80.0)); +} + +/// A line's height of 0 reads Mixed, as the command refuses a scale of 0: the dialog can still move +/// or rotate it, and typing a height sets it. +#[test] +fn transform_each_absolute_reads_mixed_for_a_side_of_zero() { + let mut app = app(); + let id = app.run("path.create", json!({"anchors": [{"x": 10, "y": 50}, {"x": 110, "y": 50}], "closed": false})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", -1.0), d.fields.get("absV")), (100.0, None), "its width, and Mixed for its height"); + app.ui.dialog.as_mut().unwrap().fields.insert("moveH".into(), json!(10)); + confirm(&mut app).unwrap(); + let b = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1), (20.0, 50.0, 120.0)); +} + +/// `ui.dialog.set` takes an Absolute size as text in a unit ("10 mm"), as the dialog's fields do, +/// and reaches the fields while they read Mixed. +#[test] +fn transform_each_control_set_takes_a_size_in_units() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x": 120, "y": 10, "width": 40, "height": 80})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields.get("absH"), None, "Mixed"); + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), "absH".into(), json!("10 mm")); + confirm(&mut app).unwrap(); + let b = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert!((b.width() - 10.0 * 72.0 / 25.4).abs() < 1e-6, "10 mm wide: {}", b.width()); +} + +/// Absolute measures the objects the command transforms: a compound path's member stands for its +/// compound, so the size shown is the compound's. +#[test] +fn transform_each_absolute_measures_the_compound_a_member_stands_for() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap(); + let inner = app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.compoundPath.make", json!({})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.run("select.add", json!({"ids": [inner]})).unwrap(); + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("absH", 0.0), d.f64("absV", 0.0)), (100.0, 100.0), "the compound's size, not the member's"); +} + +/// The link keeps the proportions of sizes given as text in a unit ("10 mm"), as the dialog's own +/// fields read them. +#[test] +fn transform_each_link_reads_sizes_in_units() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.ui.transform_each = json!({"relative": false}); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + for (field, value) in [("absH", json!("10 mm")), ("absV", json!("20 mm"))] { + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), field.into(), value); + } + set_field(app.ui.dialog.as_mut().expect("Transform Each is open"), "__constrain".into(), json!(true)); + field_set(&mut app, "__constrain", Some(&json!(false))).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.f64("__ratio", 0.0), 2.0, "20 mm is twice 10 mm"); +} + +/// The Transform effect has no link between its two scales: it scales in percentages only. +#[test] +fn the_transform_effect_has_no_scale_link() { + use crate::widgets::accessibility_tests::tree; + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap(); + let shown = tree(|ui| show(&mut app, ui.ctx())); + assert!(!shown.iter().any(|n| n.label() == Some("Constrain Width and Height Proportions")), "no scale link in the Transform effect"); +} + +/// The Transform effect keeps its percentage scales: no Relative or Absolute radio buttons. +#[test] +fn the_transform_effect_scales_in_percentages_only() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.transform"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(!text.lines().any(|l| l == "Relative" || l == "Absolute"), "{text}"); +} + #[test] fn drop_shadow_chooses_color_or_darkness() { let mut app = app(); @@ -1949,9 +2344,10 @@ fn transform_each_sliders_step_the_numbers_their_fields_show() { set(&mut app, "scaleH", json!(300)); f.key(&mut app, ArrowLeft, M::NONE); assert_eq!(get(&app, "scaleH"), 199.0); - // Tab goes through the Scale fields and sliders to the Move slider. + // Tab goes through the Scale fields and sliders, the link between them, the Relative and + // Absolute radio buttons and their two fields to the Move slider. set(&mut app, "moveH", json!(0)); - for _ in 0..4 { + for _ in 0..7 { f.key(&mut app, Tab, M::NONE); } f.key(&mut app, ArrowRight, M::NONE); diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 5bf48a739..4f576b561 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -166,7 +166,17 @@ fn scale_option(app: &VectorcraftApp, d: &Dialog, field: &str) -> bool { /// 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 { + scale_options_ordered(app, ui, d, false); +} + +/// [`scale_options`], with `strokes_first` putting Scale Strokes & Effects above Scale Corners as +/// Transform Each lists them. +pub(super) fn scale_options_ordered(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, strokes_first: bool) { + let mut options = SCALE_OPTIONS; + if strokes_first { + options.reverse(); + } + for (field, _, label) in options { let on = scale_option(app, d, field); d.fields.insert(field.into(), Value::Bool(on)); form::check(ui, d, field, tl!(label)); diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 71f5145a2..dd32fe853 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -3,14 +3,16 @@ //! Corners and Scale Strokes & Effects (`object.transformEach`), previewed live on the canvas. Its Scale and Move sliders, Rotate dial //! 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` (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). +//! Fields: `scaleH`, `scaleV` (percentages, or with Absolute each object's width and height in pt), +//! `relative` (Relative or Absolute scaling), `moveH`, `moveV` (pt), `rotate` (°), `reflectX`, +//! `reflectY`, `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`; `__constrain` is the link that keeps the two scales in +//! proportion. It opens on the values it was last confirmed with, Copy aside (#1732). use serde_json::{Value, json}; -use super::transform::{add_object_options, add_scale_options, object_options, scale_options, with_scale_options}; +use super::transform::{add_object_options, add_scale_options, object_options, scale_options_ordered, with_scale_options}; use super::{DialogSpec, form}; use crate::VectorcraftApp; use crate::state::Dialog; @@ -31,18 +33,22 @@ pub(super) const SPEC: DialogSpec = DialogSpec { alternate: Some(("Copy", "copy")), preview: true, min_width: 360.0, - inputs: |_| &["objects", "patterns", "corners", "strokes"], + inputs: |_| &["relative", "absH", "absV", "objects", "patterns", "corners", "strokes"], + // Preview sits at the left of the button row, level with Copy, Cancel and OK. + footer_left: Some(form::preview_check), ..DialogSpec::FORM }; /// The fields a fresh dialog starts with. 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}) + json!({"scaleH": 100, "scaleV": 100, "relative": true, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4, "copy": false, "__constrain": 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"]; +/// Patterns (the preference it starts from), the scale options (preferences too) and the Absolute +/// targets (`absH`, `absV`), which the dialog sets from the selection every time it opens. +const REMEMBERED: [&str; 13] = + ["scaleH", "scaleV", "relative", "moveH", "moveV", "rotate", "reflectX", "reflectY", "random", "reference", "objects", "preview", LINK]; /// 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. @@ -55,48 +61,353 @@ pub fn open_fields(app: &VectorcraftApp) -> Value { _ => {} } } + let relative = out.get("relative").and_then(Value::as_bool).unwrap_or(true); + // Absolute shows the size the selected objects share, so a dialog that opens in it has it. + if !relative { + let (w, h) = shared_size(app, app.session.general_unit()); + for (key, v) in [("absH", w), ("absV", h)] { + if let Some(v) = v { + out[key] = json!(v); + } + } + } + // A link that opens on keeps the proportions of the scales it opens on, so a scale set before + // the first frame (`ui.dialog.set`) follows it. + if out.get(LINK).and_then(Value::as_bool) == Some(true) + && let Some(ratio) = out.as_object().and_then(|f| link_ratio(f, relative)) + { + out[RATIO] = json!(ratio); + } out } -/// The command's parameters without the scale options. +/// The command's parameters without the scale options. The scales are the percentages with +/// Relative and the Absolute targets with Absolute, where a side the objects don't share (Mixed) is +/// left out, so each object keeps that side. fn params(d: &Dialog) -> Value { let f = |k: &str, v: f64| d.f64(k, v); - json!({ - "scaleH": f("scaleH", 100.0), "scaleV": f("scaleV", 100.0), "moveH": f("moveH", 0.0), "moveV": f("moveV", 0.0), + let mut p = json!({ + "relative": relative(d), + "moveH": f("moveH", 0.0), "moveV": f("moveV", 0.0), "rotate": f("rotate", 0.0), "reflectX": d.bool("reflectX"), "reflectY": d.bool("reflectY"), "random": d.bool("random"), "reference": f("reference", 4.0).clamp(0.0, 8.0) as u64, "copy": d.bool("copy"), - }) + }); + let (h, v) = scale_keys(d); + for (key, field) in [("scaleH", h), ("scaleV", v)] { + // A field the dialog doesn't hold is Mixed; one kept as text ("10 mm") parses as the + // dialog's own fields read it. + if d.fields.contains_key(field) { + p[key] = json!(d.f64(field, 100.0)); + } + } + p +} + +/// The two Scale fields the dialog edits: the percentages with Relative, the Absolute targets with +/// Absolute (`absH` and `absV`, absent when the objects differ). +fn scale_keys(d: &Dialog) -> (&'static str, &'static str) { + if relative(d) { ("scaleH", "scaleV") } else { ("absH", "absV") } +} + +/// The proportions the link keeps for the scales `fields` show (vertical ÷ horizontal): none while +/// a side is Mixed. +fn link_ratio(fields: &serde_json::Map, relative: bool) -> Option { + let (h, v) = if relative { ("scaleH", "scaleV") } else { ("absH", "absV") }; + let ratio = field_number(fields, v)? / field_number(fields, h)?; + (ratio.is_finite() && ratio != 0.0).then_some(ratio) +} + +/// The number `fields[key]` holds: a number, or a distance in text ("10 mm"), as the dialog's own +/// fields read them; none when the field is absent (Mixed). +fn field_number(fields: &serde_json::Map, key: &str) -> Option { + match fields.get(key)? { + Value::Number(n) => n.as_f64(), + Value::String(s) => vectorcraft_doc::Unit::Points.parse(s), + _ => None, + } +} + +/// Relative scaling (field `relative`, on until switched off): percentages of each object's size, +/// rather than the width and height it becomes (Absolute). +fn relative(d: &Dialog) -> bool { + d.fields.get("relative").and_then(Value::as_bool).unwrap_or(true) } /// Width of the label column and the value fields of [`sections`]. const LABEL_W: f32 = 72.0; -const FIELD_W: f32 = 70.0; +const FIELD_W: f32 = 78.0; +/// Width of the Angle field: the dial leaves it room to be wider than a Scale or Move field. +const ANGLE_FIELD_W: f32 = 94.0; +/// Width of the Constrain Width and Height Proportions link's column, at the right of the Scale +/// rows. +const LINK_W: f32 = 22.0; /// Largest scale (%) the fields take (as `object.transformEach` and the Transform effect). const MAX_SCALE: f64 = 100_000.0; +/// Largest width or height (pt) the Scale fields take with Absolute (as `object.transformEach`). +const MAX_ABSOLUTE: f64 = 1.0e7; +/// The Scale sliders' rails: percentages, and points with Absolute (the fields take values past +/// the rails). +const SCALE_RAIL: std::ops::RangeInclusive = 0.0..=200.0; +const ABSOLUTE_RAIL: std::ops::RangeInclusive = 0.0..=1000.0; -/// The Scale, Move and Rotate sections of Transform Each and the Transform effect: sliders for -/// `scaleH` and `scaleV` (rails 0–200 %) and for `moveH` and `moveV` (rails ±1000 pt, fields in -/// `units`), and an angle dial for `rotate` (counter-clockwise). The fields take values past the -/// rails. -pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, live: bool) { +/// The dialog fields of the Scale link, as the shape dialogs' Width and Height link: its state, the +/// state the dialog last applied (a change by its button or from outside shows as a difference +/// from it) and the proportions it keeps (vertical ÷ horizontal). +const LINK: &str = "__constrain"; +const APPLIED: &str = "__constrainApplied"; +const RATIO: &str = "__ratio"; + +/// The Scale section: a slider and a field for `scaleH` and for `scaleV`, percentages of each +/// object's size, or with `absolute` the width and height it becomes, in `units` (Transform Each +/// alone: the Transform effect scales in percentages), and the link that keeps them in proportion. +fn scale_section(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, absolute: bool) { + widgets::subheader(ui, tl!("Scale")); + link_changed(d); + if let Some(rows) = scale_rows(ui, d, units, false, absolute, true) { + scale_link(ui, d, rows); + } +} + +/// The two Scale rows, and with `link` the column at their right that [`scale_link`] fills. Returns +/// the two rows' bounds. While the link is on, a scale that changes sets the other. +fn scale_rows(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, live: bool, absolute: bool, link: bool) -> Option { let rail = Tokens::get(ui.ctx()).input_border; let track = move |_| rail; let axes = |h, v| [(h, tl!("Horizontal:")), (v, tl!("Vertical:"))]; - widgets::subheader(ui, tl!("Scale")); - for (key, label) in axes("scaleH", "scaleV") { - widgets::label_row(ui, label, LABEL_W, |ui| { - let shown = widgets::SliderValue::field(d.f64(key, 100.0).clamp(-MAX_SCALE, MAX_SCALE), 0.0..=200.0, "%", 2); - form::slider_rail_valued(ui, d, key, label, 0.0..=200.0, Some(shown), &track); - let value = if live { - widgets::range_field_live(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) - } else { - widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) - }; - if let Some(n) = value { - d.fields.insert(key.into(), json!(n)); - } - }); + let mut rows: Option = None; + for (key, label) in axes(if absolute { "absH" } else { "scaleH" }, if absolute { "absV" } else { "scaleV" }) { + let before = d.f64(key, 100.0); + let row = ui + .scope(|ui| { + widgets::label_row(ui, label, LABEL_W, |ui| { + if absolute { + // A side the objects don't share reads "Mixed" until a size is typed. + let shown = form::length_value(d, key, units, ABSOLUTE_RAIL); + form::slider_rail_valued(ui, d, key, label, ABSOLUTE_RAIL, shown, &track); + form::length_mixed(ui, d, key, units, FIELD_W, Some(-MAX_ABSOLUTE..=MAX_ABSOLUTE), tl!("Mixed")); + } else { + let shown = widgets::SliderValue::field(d.f64(key, 100.0).clamp(-MAX_SCALE, MAX_SCALE), SCALE_RAIL, "%", 2); + form::slider_rail_valued(ui, d, key, label, SCALE_RAIL, Some(shown), &track); + let value = if live { + widgets::range_field_live(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) + } else { + widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) + }; + if let Some(n) = value { + d.fields.insert(key.into(), json!(n)); + } + } + if link { + ui.add_space(LINK_W + ui.spacing().item_spacing.x); + } + }); + }) + .response + .rect; + if d.f64(key, 100.0) != before { + follow_link(d, key); + } + rows = Some(rows.map_or(row, |rows| rows.union(row))); } + rows +} + +/// The Constrain Width and Height Proportions link, drawn over the two Scale rows' right end as the +/// shape dialogs' link is over theirs (field [`LINK`]). +fn scale_link(ui: &mut egui::Ui, d: &mut Dialog, rows: egui::Rect) { + let rect = egui::Rect::from_center_size(egui::pos2(rows.right() - LINK_W / 2.0, rows.center().y), egui::vec2(LINK_W, LINK_W)); + let resp = ui.interact(rect, ui.scope_id().with("te-scale-link"), egui::Sense::click()); + let on = d.bool(LINK); + let icon = if on { "link" } else { "link-2-off" }; + if widgets::paint_icon_button(ui, resp, icon, "Constrain Width and Height Proportions", on).clicked() { + d.fields.insert(LINK.into(), json!(!on)); + link_changed(d); + } +} + +/// When the link's state differs from the one the dialog last applied (or none was applied yet), +/// apply it and return it: turned on, the link keeps the proportions of the scales the dialog shows +/// then; turned off, it forgets them. A scale of zero when it turns on leaves nothing to keep, so +/// typing changes only the side typed until the link is turned off and on again. +fn link_changed(d: &mut Dialog) -> Option { + let on = d.bool(LINK); + if d.fields.get(APPLIED).and_then(Value::as_bool) == Some(on) { + return None; + } + d.fields.insert(APPLIED.into(), json!(on)); + keep_link_proportions(d); + Some(on) +} + +/// The link keeps the proportions of the scales the dialog shows now (none while a side is Mixed). +fn keep_link_proportions(d: &mut Dialog) { + match link_ratio(&d.fields, relative(d)).filter(|_| d.bool(LINK)) { + Some(ratio) => { + d.fields.insert(RATIO.into(), json!(ratio)); + } + None => { + d.fields.remove(RATIO); + } + } +} + +/// After the scale `typed` ("scaleH" or "scaleV") changed with the link on, set the other one from +/// the proportions the link keeps, within the range its field takes (so the field shows what the +/// command applies at the limits). +fn follow_link(d: &mut Dialog, typed: &str) { + let ratio = d.f64(RATIO, 0.0); + let (h, v) = scale_keys(d); + if !d.bool(LINK) || !ratio.is_finite() || ratio == 0.0 || (typed != h && typed != v) { + return; + } + let (other, value) = if typed == h { (v, d.f64(h, 100.0) * ratio) } else { (h, d.f64(v, 100.0) / ratio) }; + let limit = if relative(d) { MAX_SCALE } else { MAX_ABSOLUTE }; + if value.is_finite() { + d.fields.insert(other.into(), json!(value.clamp(-limit, limit))); + } +} + +/// The Scale section's Relative and Absolute radio buttons, under its sliders (field `relative`). +/// Absolute shows the size the selected objects share, set again each time it is chosen. +fn scale_mode_radios(ui: &mut egui::Ui, d: &mut Dialog, app: &VectorcraftApp, units: vectorcraft_doc::Unit) { + let relative = relative(d); + ui.horizontal(|ui| { + ui.add_space(LABEL_W + ui.spacing().item_spacing.x); + for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { + if widgets::radio(ui, label, relative == on, true) && relative != on { + d.fields.insert("relative".into(), json!(on)); + if on { + keep_link_proportions(d); + } else { + let size = shared_size(app, units); + set_absolute_targets(d, size); + } + } + } + }); +} + +/// `ui.dialog.set` changed a field the Scale section reads: a switch to Absolute shows the size the +/// objects share, and the link (`__constrain`) and its two scales follow as a click or a typed +/// value does. +pub(super) fn field_set(app: &mut VectorcraftApp, field: &str, old: Option<&Value>) { + match field { + "relative" => { + let size = shared_size(app, app.session.general_unit()); + let Some(d) = app.ui.dialog.as_mut() else { return }; + let mode = relative(d); + if old.and_then(Value::as_bool) != Some(mode) { + if mode { + keep_link_proportions(d); + } else { + set_absolute_targets(d, size); + } + } + } + LINK => { + if let Some(d) = app.ui.dialog.as_mut() { + link_changed(d); + } + } + "scaleH" | "scaleV" | "absH" | "absV" => { + if let Some(d) = app.ui.dialog.as_mut() { + follow_link(d, field); + } + } + _ => {} + } +} + +/// The Absolute targets (`absH`, `absV`) for the selection's [`shared_size`]: the width and height +/// its objects share, and none (the fields read "Mixed") when they differ. +fn set_absolute_targets(d: &mut Dialog, (w, h): (Option, Option)) { + for (key, v) in [("absH", w), ("absV", h)] { + match v { + Some(v) => { + d.fields.insert(key.into(), json!(v)); + } + None => { + d.fields.remove(key); + } + } + } + keep_link_proportions(d); +} + +/// The objects a transform of `ids` acts on, as the command's own selection roots: a compound +/// path's member stands for its compound, and layers and objects a listed object holds are left +/// out. +fn transform_roots(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> Vec { + let roots: Vec = ids + .iter() + .map(|id| match doc.parent_of(*id).and_then(|p| doc.node(p)) { + Some(parent) if matches!(parent.kind, vectorcraft_doc::NodeKind::Compound { .. }) => parent.id, + _ => *id, + }) + .collect(); + let listed: std::collections::HashSet = roots.iter().copied().collect(); + roots.iter().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_layer()) && !held_by_listed(doc, *id, &listed)).collect() +} + +/// Whether an object `listed` holds is above `id` (one walk up, not one per pair of objects: a +/// selection can be tens of thousands of objects). +fn held_by_listed(doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId, listed: &std::collections::HashSet) -> bool { + let mut cur = id; + while let Some(parent) = doc.parent_of(cur) { + if listed.contains(&parent) { + return true; + } + cur = parent; + } + false +} + +/// The size `sizes` share, or none when they differ: sizes the panels show alike +/// ([`Unit::number`], four decimals, trailing zeros trimmed) count as the same, and the first is +/// the one shown, so the fields read the same number the Transform panel and the Control bar give +/// each object. A side of 0 (a line's height) is none too: the command refuses a scale of 0, and +/// the objects keep that side anyway. +fn shared_side(unit: vectorcraft_doc::Unit, sizes: impl Iterator) -> Option { + let mut sizes = sizes; + let first = sizes.next()?; + if first.abs() <= 1.0e-9 { + return None; + } + let shown = unit.number(first); + sizes.all(|size| unit.number(size) == shown).then_some(first) +} + +/// The width and height the selected objects share (each `None` when they differ), measured on +/// their own geometric bounds and without a preview: with Preview on, the document holds the +/// transform the dialog has shown so far. +fn shared_size(app: &VectorcraftApp, unit: vectorcraft_doc::Unit) -> (Option, Option) { + // The objects the command transforms (a compound path's member stands for its compound), each + // measured by its own box, rotated with it, as the Transform panel and the Control bar show it: + // a rotated circle's axis-aligned bounds run a hair larger than the circle. + let measure = |doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]| -> Vec<(f64, f64)> { + // One symbol cache for all of them: many instances of one symbol measure its art once. + let symbols = doc.symbol_boxes(); + transform_roots(doc, ids) + .iter() + .filter_map(|id| doc.oriented_bounds_with(&[*id], false, &symbols)) + .map(|b| (b.rect.width(), b.rect.height())) + .collect() + }; + let bounds = match app.session.active().and_then(|st| st.interaction.as_ref()) { + Some(it) => measure(&it.doc, &it.selection.objects), + None => app.session.doc().ok().map(|st| measure(&st.doc, &st.selection.objects)).unwrap_or_default(), + }; + let shared = |side: fn(&(f64, f64)) -> f64| shared_side(unit, bounds.iter().map(side)); + (shared(|b| b.0), shared(|b| b.1)) +} + +/// The Move and Rotate sections of Transform Each and the Transform effect: sliders for `moveH` +/// and `moveV` (rails ±1000 pt, fields in `units`), and an angle dial for `rotate` +/// (counter-clockwise). The fields take values past the rails. +fn move_and_rotate(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, live: bool) { + let rail = Tokens::get(ui.ctx()).input_border; + let track = move |_| rail; + let axes = |h, v| [(h, tl!("Horizontal:")), (v, tl!("Vertical:"))]; widgets::subheader(ui, tl!("Move")); for (key, label) in axes("moveH", "moveV") { widgets::label_row(ui, label, LABEL_W, |ui| { @@ -116,9 +427,9 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc let angle = d.f64("rotate", 0.0); let dialed = widgets::angle_dial(ui, "te-dial", angle, 28.0); let typed = if live { - widgets::plain_field_live(ui, ("te", "rotate"), angle, "°", 2, FIELD_W) + widgets::plain_field_live(ui, ("te", "rotate"), angle, "°", 2, ANGLE_FIELD_W) } else { - widgets::plain_field(ui, ("te", "rotate"), angle, "°", 2, FIELD_W) + widgets::plain_field(ui, ("te", "rotate"), angle, "°", 2, ANGLE_FIELD_W) }; if let Some(a) = typed.or(dialed) { d.fields.insert("rotate".into(), json!(a)); @@ -126,11 +437,25 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc }); } -/// The Reflect X, Reflect Y, reference point and Random options of Transform Each and the -/// Transform effect. -pub(super) fn reflect_options(ui: &mut egui::Ui, d: &mut Dialog) { +/// The Scale, Move and Rotate sections of Transform Each and the Transform effect: sliders for +/// `scaleH` and `scaleV` (rails 0–200 %), sliders for `moveH` and `moveV` (rails ±1000 pt, fields +/// in `units`), and an angle dial for `rotate` (counter-clockwise). The Transform effect has no +/// link between its two scales. +pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc::Unit, live: bool) { + widgets::subheader(ui, tl!("Scale")); + scale_rows(ui, d, units, live, false, false); + move_and_rotate(ui, d, units, live); +} + +/// The Reflect X, Reflect Y and Random options of Transform Each and the Transform effect. +pub(super) fn reflect_checks(ui: &mut egui::Ui, d: &mut Dialog) { form::check(ui, d, "reflectX", tl!("Reflect X")); form::check(ui, d, "reflectY", tl!("Reflect Y")); + form::check(ui, d, "random", tl!("Random")); +} + +/// The reference point locator (field `reference`) and its label. +pub(super) fn reference_point_row(ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { let cur = d.f64("reference", 4.0).clamp(0.0, 8.0) as usize; if let Some(i) = widgets::reference_point(ui, cur) { @@ -138,26 +463,44 @@ pub(super) fn reflect_options(ui: &mut egui::Ui, d: &mut Dialog) { } widgets::dim_label(ui, tl!("Reference Point")); }); +} + +/// The Reflect X, Reflect Y, reference point and Random options of the Transform effect. +pub(super) fn reflect_options(ui: &mut egui::Ui, d: &mut Dialog) { + form::check(ui, d, "reflectX", tl!("Reflect X")); + form::check(ui, d, "reflectY", tl!("Reflect Y")); + reference_point_row(ui, d); form::check(ui, d, "random", tl!("Random")); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let units = app.session.general_unit(); - ui.horizontal_top(|ui| { - ui.vertical(|ui| sections(ui, d, units, false)); - ui.add_space(16.0); - ui.vertical(|ui| { - widgets::subheader(ui, tl!("Options")); - ui.add_space(4.0); - object_options(app, ui, d); - scale_options(app, ui, d); - reflect_options(ui, d); + // The rows scroll when the window is shorter than they are, so the heading, Preview and the + // buttons stay in view. + super::modal::scrolled_rows(ui, "transform-each-rows", |ui| { + scale_section(ui, d, units, !relative(d)); + scale_mode_radios(ui, d, app, units); + move_and_rotate(ui, d, units, false); + widgets::subheader(ui, tl!("Options")); + ui.add_space(4.0); + // The options in two columns: the object and scale options, then the reflections and + // Random, which start where the Scale and Move fields do. + let fields_x = LABEL_W + 2.0 * ui.spacing().item_spacing.x + form::SLIDER_WIDTH; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(fields_x - ui.spacing().item_spacing.x); + object_options(app, ui, d); + scale_options_ordered(app, ui, d, true); + }); + ui.vertical(|ui| reflect_checks(ui, d)); }); + ui.add_space(6.0); + reference_point_row(ui, d); }); let mut p = params(d); add_object_options(app, d, &mut p); add_scale_options(app, d, &mut p); - form::preview(app, ui, d, "Transform Each", CMD, p); + form::preview_without_check(app, d, "Transform Each", CMD, p); false } @@ -167,7 +510,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = with_scale_options(app, d, 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() }; + let v = match k { + "preview" | LINK => json!(d.bool(k)), + // The percentages stay the dialog's own, whichever mode the scales were applied in. + "scaleH" | "scaleV" => json!(d.f64(k, 100.0)), + _ => p[k].clone(), + }; kept.insert(k.into(), v); } let r = form::commit_preview(app, CMD, p)?; @@ -232,6 +580,17 @@ mod tests { assert_eq!(Value::Object(d.fields.clone()), fields()); } + /// A dialog that opens with the link on keeps the proportions of the scales it opens on, so a + /// scale set before the first frame (`ui.dialog.set`) follows it. + #[test] + fn a_link_that_opens_on_keeps_the_proportions_it_opens_on() { + let (mut app, _) = square_app(); + app.ui.transform_each = json!({"scaleH": 100, "scaleV": 200, "__constrain": true}); + assert_eq!(open(&mut app).f64("__ratio", 0.0), 2.0); + app.ui.transform_each = json!({"scaleH": 100, "scaleV": 200, "__constrain": false}); + assert_eq!(open(&mut app).fields.get("__ratio"), None); + } + /// #1732: the dialog opens on the values it was last confirmed with (as Move and Offset Path /// do), but never on Copy. #[test] @@ -240,6 +599,7 @@ mod tests { let d = open(&mut app); for (k, v) in [ ("scaleH", json!(150)), + ("relative", json!(false)), ("moveH", json!(20)), ("rotate", json!(15)), ("reflectX", json!(true)), @@ -257,6 +617,7 @@ mod tests { for (k, v) in [ ("scaleH", json!(150.0)), ("scaleV", json!(100.0)), + ("relative", json!(false)), ("moveH", json!(20.0)), ("rotate", json!(15.0)), ("reflectX", json!(true)), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ed4fb0ac1..ce798eef6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -52,9 +52,9 @@ pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_labe pub(crate) use labels::{label_field, take_previous_label}; pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit}; pub use numeric::{ - SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, opt_field, opt_field_named, - plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, size_field, size_field_named, slider, spin_field, - spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, spin_plain, spin_plain_named, + SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, num_field_within_empty, opt_field, + opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, size_field, size_field_named, slider, + spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, spin_plain, spin_plain_named, }; #[cfg(test)] use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 7f619aa23..dfa68ac12 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -59,20 +59,35 @@ pub fn num_field_within( bounds: RangeInclusive, ) -> Option { let label = take_previous_label(ui); - unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false) + unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false, "") +} + +/// [`num_field_within`] whose empty value reads `empty` (Transform Each's Absolute scales read +/// "Mixed" when the selected objects differ): typing replaces it, as it replaces the value shown. +pub fn num_field_within_empty( + ui: &mut Ui, + id: impl std::hash::Hash, + value: Option, + empty: &str, + unit: Unit, + width: f32, + bounds: RangeInclusive, +) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false, empty) } /// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with /// its label painted separately): screen readers name the field `name` (already translated). pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { let label = take_previous_label(ui); - unit_field(ui, id, label, name, value, unit, width, false, ANY, &[], false) + unit_field(ui, id, label, name, value, unit, width, false, ANY, &[], false, "") } /// The field returns valid typed distances immediately and preserves the text being edited. pub fn num_field_live(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { let label = take_previous_label(ui); - unit_field(ui, id, label, None, value, unit, width, false, ANY, &[], true) + unit_field(ui, id, label, None, value, unit, width, false, ANY, &[], true, "") } /// Any number: the bounds of a field that has none. @@ -100,9 +115,10 @@ fn unit_field( bounds: RangeInclusive, stops: &[f64], live: bool, + empty: &str, ) -> Option { let id = id_with(ui.scope_id(), id); - let shown = value.map(|v| unit.format(v)).unwrap_or_default(); + let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string()); take_dialog_focus(ui, id, &shown); let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); select_all_on_focus(ui, &resp, &buf); @@ -332,7 +348,7 @@ pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str } }); } - unit_field(ui, id, label, name, value, unit, width, true, ANY, &[], false) + unit_field(ui, id, label, name, value, unit, width, true, ANY, &[], false, "") } /// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels @@ -600,7 +616,7 @@ pub fn spin_field_stops_named( ) -> Option { let label = take_previous_label(ui); spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false) + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, "") }) .and_then(SpinPick::value) } @@ -657,7 +673,7 @@ pub fn spin_field_auto_named( ) -> Option { let label = take_previous_label(ui); spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false) + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false, "") }) } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2aa1e3496..04d76ae90 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -484,14 +484,15 @@ equivalents); `ui.dialog.confirm` runs `swatch.spotOptions {useLab}` as one undo Scale options: `ui.menuDialog {command}` opens the dialog a command's menu item opens (`object.scale` → `scale`, `object.transformEach` → `transformEach`, also Move, Rotate, Reflect, Shear and the Path dialogs). The Scale dialog (`scale`) and Object → Transform → Transform Each… (dialog `transformEach`: `scaleH`, -`scaleV` %, `moveH`, `moveV` pt, `rotate` °, `reflectX`, `reflectY`, `random`, `reference` 0–8, `copy`, `preview`) +`scaleV` %, and `absH`, `absV` pt, the width and height each object becomes with `relative` false, absent while they +read "Mixed"; `moveH`, `moveV` pt, `rotate` °, `reflectX`, `reflectY`, `random`, `reference` 0–8, `copy`, `preview`) 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`. 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. +confirmed with (scale, Relative or Absolute, the link between the two scales, 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 @@ -523,7 +524,8 @@ opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `loc normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them. Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's gutters and totals, -Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the +Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV` and its Absolute `scaleH`/`scaleV`, New +Document's `width`/`height`, the length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a document). Through `ui.dialog.set` a number is in points and a string may carry its unit (`"10 mm"`; a bare number in a string is points). The `preferences` dialog's `unitsGeneral` starts as the active document's units, and OK sets them. diff --git a/docs/mcp.md b/docs/mcp.md index 79a4a5749..30a7da8e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1141,7 +1141,7 @@ nothing. These Object menu commands refuse a string, a number or another value that is not true or false with `` `copy` must be true or false ``, naming the key, and change nothing: `reflectX`, `reflectY`, `random`, `copy`, -`strokes`, `corners`, `objects` and `patterns` in `object.transformEach`; `gray` and `deleteRaster` in +`relative`, `strokes`, `corners`, `objects` and `patterns` in `object.transformEach`; `gray` and `deleteRaster` in `object.createObjectMosaic`; `trim` in `object.cropImage`; `allArtboards` in `object.createTrimMarks`; and `byColumn`, `moveArtwork` and `patterns` in `artboard.rearrange`. A null is read as a key left out. `object.createTrimMarks` refuses a `style` that is not a string, such as `5`, with `` `style` must be roman or japanese ``, the message it gives @@ -3202,6 +3202,19 @@ used, as a transform's does. {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` +## Transform Each: Relative and Absolute + +`object.transformEach` scales by percentages of each object's size (the dialog's Relative, the default), or with +`relative: false` to the width and height each object becomes: `scaleH` and `scaleV` in pt, as the dialog's Absolute. +Absolute is measured on each object's own geometric bounds, so objects of different sizes come out alike about their +own reference points. A side left out keeps its size, a side the object doesn't have (a line's height, say) keeps its +factor 1, and a scale of 0 is refused in either mode, as it collapses the object. `object.transformAgain` repeats the +Absolute target, so it leaves objects that already have that size as they are; repeat a scale with Relative instead. + +```json +{"name":"run_command","arguments":{"command":"object.transformEach","params":{"relative":false,"scaleH":50,"scaleV":50}}} +``` + ## Live Corners `object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0792a4283..05c14203e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Transform Each: Relative and Absolute scaling and the documented panel order; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -23,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons (`object.transformEach` `relative`: percentages of each object's size, or the width and height it becomes, measured on each object's own bounds) and the link that keeps the two scales in proportion; the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order. Scores and estimates unchanged. | | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | @@ -768,8 +769,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). - Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). - Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built. + Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. + Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e1e94ad22..df62d6dd2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Select by Artboards add-on selects by overlap, inside or outside several artboards; Cmd+Space keeps the active drawing cursor during its drag; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Transform Each: Relative and Absolute scaling and the documented panel order; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -30,6 +30,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Transform Each's Scale has the documented Relative and Absolute radio buttons: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`); the two scales have the link that keeps them in proportion (`__constrain`, as the shape dialogs'), and the panel lists Scale, Move, Rotate, the Options in two columns, the Reference Point and Preview (level with Copy, Cancel and OK) in the documented order; scores and estimates unchanged | | 2026-11-04 | minor | Cmd+Space pressed during an active drawing drag keeps that tool's cursor; clarify that Space can move an in-progress shape or anchor. Scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | File › Export › Export As PNG… exports the active artboard, or each artboard the Artboard tool has selected (one file each), instead of always the first (#5502); a choice the caller gives is kept; scores and estimates unchanged | @@ -204,7 +205,9 @@ tools, never checked side by side) isn't captured row by row. | The Transform effect dialog previews valid typed scale, move, rotation, and Copies values while fields remain focused. Preview off defers the artwork change until OK; Cancel restores the original artwork. | done | `crates/ui-egui/src/panels/tests_appearance.rs` covers numeric input, sliders, artwork targets, preview, and undo. | | Move opens on the offset of the last move (a drag of the selection or Move) and with the Copy, Transform Objects and Preview it was last confirmed with, so Enter, Enter repeats a move or a copy | done | the public source: [an Adobe Community thread](https://community.adobe.com/questions-652/move-tool-771221) (the dialog shows the last move, drags included); that the checkboxes are kept, Preview among them, and that a nudge with the arrow keys is not a move here are our choices; Transform Patterns is General › Transform Pattern Tiles, which Move's OK sets; the offset is the session's (not kept between launches) | | Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with, and has a Copy button left of Cancel that transforms a copy of each object (no Copy checkbox) | done | #1732; the Copy button: [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html); the remembered values: a maintainer's call on [#1732](https://github.com/storytold/vectorcraft/issues/1732#issuecomment-6157505075), not the help page; Copy itself is never kept; Preview still starts off on first use (the owner's call) | -| Transform Objects beside Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and Transform Each; Reflect across an angle | done | #1448, #1460; off, only the pattern tiles transform; the checkboxes are always enabled (our choice); the link that keeps Transform Each's two scales equal is missing (not documented) | +| Transform Each's Scale has Relative and Absolute: percentages of each object's size, or the width and height it becomes | done | `object.transformEach` `relative`; behaviour reference: [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html), "Switch between Relative and Absolute… exact values"; Absolute measures each object's own box; the scales act along its axes, about that box's reference point, so a turned shape keeps its shape; the fields show the width and height the selected objects share (not the selection's bounds) and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), a size typed setting every object to it, our choices where the page leaves them open (a side left Mixed is left as it is); Relative keeps its own percentages, and Transform Again repeats the Absolute target, which leaves objects that already have that size as they are; a live Repeat scales its spacing by the geometric mean of the two factors, so Absolute reaches its size only approximately (its layout is its own) | +| Transform Each's Scale has the link that keeps its two values in proportion, and the panel lists Scale, Move, Rotate, the Options, the Reference Point and Preview in the documented order (Preview level with Copy, Cancel and OK) | done | the link (`__constrain`, as the shape dialogs' Width and Height link): on, a scale sets the other so the two keep the proportions they had when it was turned on, and the dialog opens with it as it was last confirmed; the order and Preview's place in the button row follow the owner's mockup; the link's placement beside the two rows is our choice (the public pages don't establish it) | +| Transform Objects beside Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and Transform Each; Reflect across an angle | done | #1448, #1460; off, only the pattern tiles transform; the checkboxes are always enabled (our choice) | | Split Into Grid dialog: number, height or width, gutter and total for rows and columns, Add Guides, Preview | done | #1462; opens at the selection's size; several paths become one grid painted as the topmost one; a typed height or width keeps the gutter and changes the total (our choice) | | Reference point locator (nine squares) in Control bar and Transform panel | done | | | Transform panel menu: Transform Object Only, Transform Pattern Only, Transform Both (one checked), Use Registration Point for Symbol | done | `transformPanelPatterns` (`object`, `pattern`, `both`) sends `objects` and `patterns` with every field, flip and menu flip of the Transform panel and the Properties panel's Transform section; `useRegistrationPointForSymbol` makes them measure and transform one selected symbol instance from its symbol's registration point (Symbol Options › Registration), where the instance's flips, rotations and shears put it, instead of the reference point; the Properties panel's rotation and flips turn and mirror about it too ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html)); until a choice is made (`default`) the panels follow General › Transform Pattern Tiles as before, the menu checking Transform Both while it is on and Transform Object Only while it is off |