From 6992c843b40890ce3f46b214e27c351385a1bae2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 23:58:12 +0700 Subject: [PATCH] Fix #40: W/H constrain-proportions link in the Properties panel and Control bar The Transform panel had the link, but kept it in panel-local egui memory; the Properties panel's Transform section and the Control bar had none, so size changes there were never proportional. The link is now the constrainProportions preference (prefs.get/prefs.set), one toggle shared by the three places (transform::constrain_link), and every W/H field passes it to object.setBounds as `proportional`. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/lib.rs | 5 ++ crates/ui-egui/src/chrome.rs | 7 +- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/properties.rs | 9 ++- crates/ui-egui/src/panels/tests_constrain.rs | 74 ++++++++++++++++++++ crates/ui-egui/src/panels/transform.rs | 15 ++-- docs/mcp.md | 11 +++ 8 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/panels/tests_constrain.rs diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 5919afd9..fb56a320 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -320,6 +320,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool), // Native saves (`document.save {compress}`) p!("useCompression", "File Handling", "Files", "Use Compression", bool), + // The W/H link of the Transform panel, the Properties panel and the Control bar + p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8abfa657..fa851c08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -472,6 +472,10 @@ pub struct Prefs { /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub print_presets: Vec, + /// Constrain Width and Height Proportions: the link between W and H in the Transform panel, + /// the Properties panel and the Control bar (their size fields pass `proportional` to + /// `object.setBounds`). + pub constrain_proportions: bool, } impl Default for Prefs { @@ -604,6 +608,7 @@ impl Default for Prefs { web_export_presets: vec![], web_export_settings: None, print_presets: vec![], + constrain_proportions: false, } } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 77ed1d69..aa2fdedf 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -203,10 +203,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // The bounding box, rotated with rotated objects: its centre and its own sides. if let Some(b) = app.selection_box() { let c = b.center(); + let link = app.session.prefs.constrain_proportions; for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] { + // The W/H link sits between W and H. + if k == "height" { + crate::panels::transform::constrain_link(app, ui); + } ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) { - app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); + app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok(); } } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 055732d8..edf4467e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -455,6 +455,8 @@ mod tests_appearance; #[cfg(test)] mod tests_asset_export; #[cfg(test)] +mod tests_constrain; +#[cfg(test)] mod tests_css_properties; #[cfg(test)] mod tests_effectedit; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d99ec6f8..3a61bc0a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -268,7 +268,9 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); } ui.add_space(6.0); - let fw = ((ui.available_width() - 44.0) / 2.0).clamp(60.0, 110.0); + let link = app.session.prefs.constrain_proportions; + // Room for the labels and the W/H link. + let fw = ((ui.available_width() - 70.0) / 2.0).clamp(60.0, 110.0); egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { dim_label(ui, "X:"); if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) { @@ -276,7 +278,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { } dim_label(ui, "W:"); if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) { - app.run("object.setBounds", json!({"width": v, "reference": refi})).ok(); + app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); dim_label(ui, "Y:"); @@ -285,10 +287,11 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { } dim_label(ui, "H:"); if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) { - app.run("object.setBounds", json!({"height": v, "reference": refi})).ok(); + app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); }); + super::transform::constrain_link(app, ui); }); ui.horizontal(|ui| { icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon); diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs new file mode 100644 index 00000000..c074b3ff --- /dev/null +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -0,0 +1,74 @@ +//! The W/H link (Constrain Width and Height Proportions) in the Transform panel, the Properties +//! panel's Transform section and the Control bar: one preference, passed on as `proportional`. + +use egui::{Event, Key, Modifiers, PointerButton}; +use serde_json::json; + +use super::tests_appearance::{frame_raw, run}; +use super::*; + +/// What draws a set of size fields. +type Draw = fn(&mut VectorcraftApp, &mut Ui); + +/// A selected 100 × 50 pt rectangle. +fn rect_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 50}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + app +} + +fn size(app: &VectorcraftApp) -> (f64, f64) { + let st = app.session.active().unwrap(); + let b = st.doc.bounds_of(&st.selection.objects, false).unwrap(); + (b.width().round(), b.height().round()) +} + +/// Type `value` into the W field (the one showing "100 pt") of what `draw` shows, and press Enter. +fn set_width(app: &mut VectorcraftApp, draw: Draw, value: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 1.0; + frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, draw) + }; + let shown = frame(app, vec![]); + let at = shown.iter().find(|(s, _)| s == "100 pt").unwrap_or_else(|| panic!("no W field in {shown:?}")).1.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(app, vec![Event::PointerMoved(at), button(true), button(false)]); + // Focus selected the whole field: typing replaces it. + frame(app, vec![]); + frame(app, vec![Event::Text(value.into())]); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(app, vec![enter]); +} + +#[test] +fn the_link_keeps_proportions_in_every_size_field() { + let surfaces: [(&str, Draw); 3] = + [("Transform panel", transform::show), ("Properties", properties::transform_section), ("Control bar", crate::chrome::control_bar)]; + for (name, draw) in surfaces { + // Off: W alone. + let mut app = rect_app(); + set_width(&mut app, draw, "200"); + assert_eq!(size(&app), (200.0, 50.0), "{name}, unlinked"); + // On: H follows. + let mut app = rect_app(); + run(&mut app, "prefs.set", json!({"key": "constrainProportions", "value": true})); + set_width(&mut app, draw, "200"); + assert_eq!(size(&app), (200.0, 100.0), "{name}, linked"); + } +} + +#[test] +fn the_link_is_a_preference_agents_can_read_and_set() { + let mut app = rect_app(); + assert_eq!(run(&mut app, "prefs.get", json!({"key": "constrainProportions"})), json!(false)); + run(&mut app, "prefs.set", json!({"key": "constrainProportions", "value": true})); + assert!(app.session.prefs.constrain_proportions); + // What the fields send with the link on. + run(&mut app, "object.setBounds", json!({"height": 100, "reference": 4, "proportional": true})); + assert_eq!(size(&app), (200.0, 100.0)); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 823b0bc0..54c061d0 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -33,7 +33,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bx = app.selection_box(); let bounds = bx.map(|b| b.rect); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); - let link: bool = pstate(ui.ctx(), "xf-link"); + let link = app.session.prefs.constrain_proportions; let has = bounds.is_some(); let rp = bx.map(|b| b.reference_point(refi)); ui.horizontal(|ui| { @@ -63,9 +63,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.end_row(); }); }); - if widgets::icon_button(ui, if link { "link" } else { "link-2-off" }, "Constrain Width and Height Proportions", link, 22.0).clicked() { - set_pstate(ui.ctx(), "xf-link", !link); - } + constrain_link(app, ui); }); ui.add_space(4.0); let origin = rp.map(|p| json!([p.x, p.y])); @@ -139,6 +137,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the +/// `constrainProportions` preference, which the size fields pass on as `proportional`. +pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { + let on = app.session.prefs.constrain_proportions; + if widgets::icon_button(ui, if on { "link" } else { "link-2-off" }, "Constrain Width and Height Proportions", on, 22.0).clicked() { + set_pref(app, "constrainProportions", !on); + } +} + /// Toggle a boolean preference (through `prefs.set`, as agents do). pub fn set_pref(app: &mut VectorcraftApp, key: &str, on: bool) { if let Err(e) = app.run("prefs.set", json!({ "key": key, "value": on })) { diff --git a/docs/mcp.md b/docs/mcp.md index de7d4d78..f5cb473e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1667,3 +1667,14 @@ its undo history are as if the click never happened. Commands on the edited text ```json {"name":"run_command","arguments":{"command":"text.discardEmpty","params":{"id":42}}} ``` + +## Constrain proportions + +The link between W and H in the Transform panel, the Properties panel and the Control bar is the +`constrainProportions` preference (`prefs.get` / `prefs.set`). With it on, those fields send `proportional: true` +to `object.setBounds`, which scales the other dimension by the same factor; agents pass `proportional` themselves. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"constrainProportions","value":true}}} +{"name":"run_command","arguments":{"command":"object.setBounds","params":{"width":200,"proportional":true}}} +```