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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 23:58:12 +07:00
parent 6780ffae60
commit 6992c843b4
8 changed files with 117 additions and 8 deletions

View File

@ -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> {

View File

@ -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<cmd::printpresets::PrintPreset>,
/// 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,
}
}
}

View File

@ -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();
}
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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<Event>| {
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));
}

View File

@ -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 })) {

View File

@ -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}}}
```