collective

Browse / github.com/storytold/vectorcraft / patch

G1: Transform Each and Transform effect link horizontal and vertical scale

G1: Transform Each and Transform effect link horizontal and vertical scale ## Summary Transform Each and Effect › Distort › Transform share scale fields; a link control keeps horizontal and vertical scale equal while it is on, matching the Scale dialog's Uniform option and Illustrator's linked proportions. ## Plan - Add `__uniform` (default on) to Transform Each default fields; infer off when reopening an effect whose scales differ. - Show a link icon on the Scale section; while linked, editing either scale updates the other before preview. - Apply linked scales in `params()` so preview and OK send matching `scaleH` / `scaleV`. - Regression test in `transform_each.r

Status
open
Score
0.00
Adopters
0 other operators
Author
nrq6993-a11y (imported from pull request #3067, open)
Base
e2a8280134a7c3c9b4e117ee668b9b3e7eb3e315
Size
+84 −7 in 2 files
Review
pending
Shared
2026-10-28
Id
32c055c7bcce03b18ba9ad02

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/dialogs/tests.rs crates/ui-egui/src/dialogs/transform_each.rs

diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs--- a/crates/ui-egui/src/dialogs/tests.rs+++ b/crates/ui-egui/src/dialogs/tests.rs@@ -1768,9 +1768,9 @@ 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, link control and sliders to the Move slider.     set(&mut app, "moveH", json!(0));-    for _ in 0..4 {+    for _ in 0..5 {         f.key(&mut app, Tab, M::NONE);     }     f.key(&mut app, ArrowRight, M::NONE);diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs--- a/crates/ui-egui/src/dialogs/transform_each.rs+++ b/crates/ui-egui/src/dialogs/transform_each.rs@@ -6,7 +6,8 @@ //! 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).+//! updates), `preview` and `__uniform` (linked horizontal and vertical scale, UI-only). It opens on+//! the values it was last confirmed with, Copy aside (#1732).  use serde_json::{Value, json}; @@ -37,7 +38,47 @@ pub(super) const SPEC: DialogSpec = DialogSpec {  /// 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, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": false, "reference": 4, "copy": false,+        "__uniform": false,+    })+}++/// Are horizontal and vertical scale linked (`__uniform`)? When that key is absent, linked only if+/// the two scale values already match.+fn uniform(d: &Dialog) -> bool {+    d.fields.get("__uniform").and_then(Value::as_bool).unwrap_or_else(|| (d.f64("scaleH", 100.0) - d.f64("scaleV", 100.0)).abs() < 1e-9)+}++fn scale_pair_key(key: &str) -> Option<(&'static str, &'static str)> {+    match key {+        "scaleH" => Some(("scaleH", "scaleV")),+        "scaleV" => Some(("scaleV", "scaleH")),+        _ => None,+    }+}++/// Set one scale field; while [`uniform`] is on, set the other to the same value.+fn set_scale(d: &mut Dialog, key: &str, value: f64) {+    d.fields.insert(key.into(), json!(value));+    if uniform(d)+        && let Some((_, other)) = scale_pair_key(key)+    {+        d.fields.insert(other.into(), json!(value));+    }+}++/// After a slider may have changed `key`, mirror to the other axis when linked.+fn mirror_scale_slider(d: &mut Dialog, key: &str, before: f64) {+    if !uniform(d) {+        return;+    }+    let after = d.f64(key, 100.0);+    if (after - before).abs() > 1e-9+        && let Some((_, other)) = scale_pair_key(key)+    {+        d.fields.insert(other.into(), json!(after));+    } }  /// The fields kept from the last OK or Copy (`UiState::transform_each`): all but Copy, Transform@@ -61,8 +102,10 @@ pub fn open_fields(app: &VectorcraftApp) -> Value { /// The command's parameters without the scale options. fn params(d: &Dialog) -> Value {     let f = |k: &str, v: f64| d.f64(k, v);+    let scale_h = f("scaleH", 100.0);+    let scale_v = if uniform(d) { scale_h } else { f("scaleV", 100.0) };     json!({-        "scaleH": f("scaleH", 100.0), "scaleV": f("scaleV", 100.0), "moveH": f("moveH", 0.0), "moveV": f("moveV", 0.0),+        "scaleH": scale_h, "scaleV": scale_v, "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"),     })@@ -83,14 +126,24 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc     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") {+    let linked = uniform(d);+    let tip = tl!("Constrain Width and Height Proportions");+    for (i, (key, label)) in axes("scaleH", "scaleV").into_iter().enumerate() {+        let before = d.f64(key, 100.0);         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);             if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) {-                d.fields.insert(key.into(), json!(n));+                set_scale(d, key, n);+            }+            if i == 0 && widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 22.0).clicked() {+                d.fields.insert("__uniform".into(), json!(!linked));+                if !linked {+                    d.fields.insert("scaleV".into(), json!(d.f64("scaleH", 100.0)));+                }             }         });+        mirror_scale_slider(d, key, before);     }     widgets::subheader(ui, tl!("Move"));     for (key, label) in axes("moveH", "moveV") {@@ -194,6 +247,30 @@ mod tests {         assert_eq!((cmd.as_str(), &p["strokes"], &p["objects"]), (CMD, &json!(false), &json!(true)), "#1448: Transform Objects goes along");     } +    #[test]+    fn linked_scales_keep_horizontal_and_vertical_equal() {+        let mut app = VectorcraftApp::new(Session::new(), Default::default());+        app.run("file.new", json!({"width": 300, "height": 300})).unwrap();+        app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap();+        app.run("select.all", json!({})).unwrap();+        app.run("ui.menuDialog", json!({ "command": CMD })).unwrap();+        let d = app.ui.dialog.as_mut().unwrap();+        assert!(!d.bool("__uniform"), "scales start unlinked so the first field is horizontal scale alone");+        set_scale(d, "scaleH", 150.0);+        assert_eq!((d.f64("scaleH", 0.0), d.f64("scaleV", 0.0)), (150.0, 100.0));+        d.fields.insert("__uniform".into(), json!(true));+        set_scale(d, "scaleH", 150.0);+        assert_eq!((d.f64("scaleH", 0.0), d.f64("scaleV", 0.0)), (150.0, 150.0));+        d.fields.insert("__uniform".into(), json!(false));+        set_scale(d, "scaleV", 80.0);+        assert_eq!((d.f64("scaleH", 0.0), d.f64("scaleV", 0.0)), (150.0, 80.0));+        let p = params(d);+        assert_eq!((&p["scaleH"], &p["scaleV"]), (&json!(150.0), &json!(80.0)));+        d.fields.insert("__uniform".into(), json!(true));+        let p = params(d);+        assert_eq!((&p["scaleH"], &p["scaleV"]), (&json!(150.0), &json!(150.0)));+    }+     /// An app with a selected 50 × 50 square at (20, 20).     fn square_app() -> (VectorcraftApp, NodeId) {         let mut app = VectorcraftApp::new(Session::new(), Default::default());