collective

Browse / github.com/storytold/deckcraft / patch

Format Shape: Scale Height/Width and a From origin in Size & Properties

Format Shape: Scale Height/Width and a From origin in Size & Properties Adds Scale Height and Scale Width (%) and a From selector (Top Left Corner / Center) for Horizontal/Vertical to the Format Shape pane's Size & Properties tab, matching the Size and Position dialog. - Scale is measured from the size the tab first showed, as in the dialog, and runs `shape.sizePosition`; Lock aspect ratio applies. - From = Center shows and takes the centre of the box; the command still receives the top-left corner. - The Lock aspect ratio checkbox used a fresh id each frame, so it never kept its state; it now does. - New kittest `the_format_pane_scales_and_positions_from_the_cen

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1447, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+85 −3 in 2 files
Review
pending
Shared
2026-10-28
Id
1b0f23aaa1207792514541c2

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/panels/format_shape.rs crates/ui-egui/src/units_tests.rs

diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs--- a/crates/ui-egui/src/panels/format_shape.rs+++ b/crates/ui-egui/src/panels/format_shape.rs@@ -147,9 +147,16 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {             }         }         "size" => {-            let lock_id = egui::Id::unique("fmt-lock-aspect");+            let lock_id = ui.scope_id().with("fmt-lock-aspect");             let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false));             let locked = lock;+            // Scale is measured from the size the shape had when this tab first showed it, like the+            // Size and Position dialog; the origin choice is kept for the session.+            let base_id = ui.scope_id().with(("fmt-scale-base", uid, sh.id.0));+            let (base_w, base_h): (f64, f64) = ui.data_mut(|d| d.get_temp(base_id).unwrap_or((x.w, x.h)));+            ui.data_mut(|d| d.insert_temp(base_id, (base_w, base_h)));+            let origin_id = ui.scope_id().with("fmt-origin-center");+            let mut center: bool = ui.data_mut(|d| d.get_temp(origin_id).unwrap_or(false));             let len = app.session.prefs.units;             let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| {                 let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) };@@ -168,13 +175,50 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) {             fields(ui, "Height", "fh", x.h, "h", "shape.resize", false);             fields(ui, "Width", "fw", x.w, "w", "shape.resize", false);             fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true);+            for (label, id, cur, base, key) in [("Scale Height", "fsh", x.h, base_h, "h"), ("Scale Width", "fsw", x.w, base_w, "w")] {+                ui.horizontal(|ui| {+                    ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));+                    let pct = if base > 0.0 { cur / base * 100.0 } else { 100.0 };+                    if let Some(nv) = widgets::spinner(ui, id, label, pct, 1.0, "%", 70.0)+                        && nv > 0.0+                    {+                        let mut p = json!({key: base * nv / 100.0});+                        if locked {+                            p["lockAspect"] = json!(true);+                        }+                        run(app, "shape.sizePosition", p);+                    }+                });+            }             if ui.checkbox(&mut lock, "Lock aspect ratio").changed() {                 ui.data_mut(|d| d.insert_temp(lock_id, lock));             }             ui.add_space(6.0);             ui.label(egui::RichText::new("Position").font(theme::bold(13.0)));-            fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false);-            fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false);+            ui.horizontal(|ui| {+                ui.add_sized(vec2(90.0, 20.0), egui::Label::new("From"));+                let combo =+                    egui::ComboBox::from_id_salt("fmt-origin").selected_text(if center { "Center" } else { "Top Left Corner" }).show_ui(ui, |ui| {+                        ui.selectable_value(&mut center, false, "Top Left Corner");+                        ui.selectable_value(&mut center, true, "Center");+                    });+                combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "From"));+            });+            ui.data_mut(|d| d.insert_temp(origin_id, center));+            let (dx, dy) = if center { (x.w / 2.0, x.h / 2.0) } else { (0.0, 0.0) };+            // The spinner shows `value / unit`; `shape.move` takes the top-left corner, so a centre+            // reading is shifted by half the size in both directions.+            let (px, py) = (x.x + dx, x.y + dy);+            let mut pos = |ui: &mut Ui, label: &str, id: &str, v: f64, off: f64, key: &str| {+                ui.horizontal(|ui| {+                    ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));+                    if let Some(nv) = widgets::spinner(ui, id, label, v / len.points_per_unit(), 0.1, len.suffix(), 70.0) {+                        run(app, "shape.move", json!({key: nv * len.points_per_unit() - off}));+                    }+                });+            };+            pos(ui, "Horizontal", "fx", px, dx, "x");+            pos(ui, "Vertical", "fy", py, dy, "y");             ui.add_space(6.0);             ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0)));             let id = ui.scope_id().with(("alt", uid, sh.id.0));diff --git a/crates/ui-egui/src/units_tests.rs b/crates/ui-egui/src/units_tests.rs--- a/crates/ui-egui/src/units_tests.rs+++ b/crates/ui-egui/src/units_tests.rs@@ -211,3 +211,41 @@ fn the_ruler_counts_centimetres_in_halves_and_inches_in_quarters() {     // A hand-edited prefs.json can say points: the ruler stays in inches rather than a tick per point.     assert_eq!(crate::canvas::ruler_steps(Unit::Points), (72.0, 4)); }++fn type_into(h: &mut Harness<'_, SlideApp>, shown: &str, text: &str) {+    h.query_all_by_role(Role::TextInput).rfind(|n| n.value().as_deref() == Some(shown)).unwrap().focus();+    h.run_steps(1);+    h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);+    h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text(text);+    h.run_steps(1);+    h.key_press(egui::Key::Enter);+    h.run_steps(3);+}++/// Format Shape › Size & Properties: Scale Width/Height and the From origin of Position.+#[test]+fn the_format_pane_scales_and_positions_from_the_centre() {+    let mut app = app_with_shape(Unit::default());+    app.ui.pane = Some("format".into());+    app.ui.format_tab = "size".into();+    let mut h = harness(app);+    h.run_steps(3);+    let shown = text_values(&h);+    assert_eq!(shown.iter().filter(|s| *s == "100%").count(), 2, "{shown:?}");+    // 50% Scale Width (the last "100%" row) halves the 144 pt width.+    type_into(&mut h, "100%", "50");+    let x = selected_xfrm(h.state());+    assert!((x.w - 72.0).abs() < 1e-6 && (x.h - 108.0).abs() < 1e-6, "{x:?}");+    assert!(text_values(&h).iter().any(|s| s == "50%"));+    // From Center: the fields read the middle of the box, and typing moves the middle.+    h.get_by_role_and_label(Role::ComboBox, "From").click();+    h.run_steps(2);+    h.get_by_label("Center").click();+    h.run_steps(3);+    // Box is now (72, 36, 72, 108): its centre is (108, 90) pt = (1.5", 1.25").+    let shown = text_values(&h);+    assert!(shown.iter().any(|s| s == "1.5\"") && shown.iter().any(|s| s == "1.25\""), "{shown:?}");+    type_into(&mut h, "1.5\"", "3");+    let x = selected_xfrm(h.state());+    assert!((x.x - 180.0).abs() < 1e-6 && (x.w - 72.0).abs() < 1e-6 && (x.y - 36.0).abs() < 1e-6, "{x:?}");+}