From 7d18b4481c8aa6ebbb41286edf2cd467a9cb2454 Mon Sep 17 00:00:00 2001 From: nevarem <349680237+nevarem@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:39 +0800 Subject: [PATCH 1/2] Shapes: the Rectangle and Ellipse dialogs link Width and Height A click with the Rectangle, Rounded Rectangle or Ellipse tool opens a dialog with Width and Height. It now has a Constrain Width and Height Proportions link beside the two rows, drawn like the Transform panel's: while it is on, typing one side sets the other at the proportions the shape had when the link was turned on. The next of these dialogs opens with the link as the last one left it. Width now comes before Height in these dialogs: the fields were listed by name, so Height came first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EjfPR4a1jPek7r3nyFFNYv --- crates/ui-egui/src/dialogs/shapes.rs | 177 +++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index bc9f2916f..ea241ad8f 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -181,6 +181,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); } else if d.kind == "arc" { arc_form(ui, d, unit); + } else if LINKED.contains(&d.kind.as_str()) { + linked_size_form(ui, d, unit); } else { form::grid(ui, d, unit); } @@ -250,6 +252,86 @@ fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { form::check(ui, d, "fillArc", tl!("Fill Arc")); } +/// The click dialogs whose Width and Height share a Constrain Width and Height Proportions link +/// (, +/// ): the same +/// link as the Transform panel's ([`crate::panels::transform::constrain_link`]), drawn the same way. +const LINKED: [&str; 3] = ["rectangle", "roundedRectangle", "ellipse"]; + +/// The dialog field holding the link's state (UI-only, as every `__` field is). +const LINK: &str = "__constrain"; + +/// The dialog field holding height ÷ width while the link is on: the proportions it keeps. +const RATIO: &str = "__ratio"; + +/// Where the link's state waits for the next of these dialogs (egui memory, this run only). +const LINK_STATE: &str = "shape-size-link"; + +/// Width and Height (then a rounded rectangle's radius) with the Constrain Width and Height +/// Proportions link beside the two size rows. While it is on, typing one side sets the other, so +/// the shape keeps the proportions it had when the link was turned on. The next of these dialogs +/// opens with the link as this one left it. +fn linked_size_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + if !d.fields.contains_key(LINK) { + set_link(d, crate::panels::pstate(ui.ctx(), LINK_STATE)); + } else if d.bool(LINK) && !d.fields.contains_key(RATIO) { + // Turned on from outside the dialog (`ui.dialog.set`): keep the sizes it shows now. + set_link(d, true); + } + const BUTTON: f32 = 22.0; + let mut rows: Option = None; + egui::Grid::new("shape-size-dlg").num_columns(3).spacing([10.0, 8.0]).show(ui, |ui| { + for (key, label) in [("width", "Width:"), ("height", "Height:")] { + let row = form::row_label(ui, label); + if ui.label_inputs_by(row.id, |ui| form::length(ui, d, key, unit, form::FIELD_W)) { + follow_link(d, key); + } + // The link's column: the button is drawn over both rows' cells once the grid is laid out. + let (_, cell) = ui.allocate_space(egui::vec2(BUTTON, BUTTON)); + rows = Some(rows.map_or(cell, |r| r.union(cell))); + ui.end_row(); + } + if d.kind == "roundedRectangle" { + form::length_field(ui, d, "radius", &form::humanized("radius"), unit); + } + }); + let Some(rows) = rows else { return }; + let on = d.bool(LINK); + let rect = egui::Rect::from_center_size(rows.center(), egui::vec2(BUTTON, BUTTON)); + let resp = ui.interact(rect, ui.scope_id().with("shape-size-link"), egui::Sense::click()); + let icon = if on { "link" } else { "link-2-off" }; + if widgets::paint_icon_button(ui, resp, icon, "Constrain Width and Height Proportions", on).clicked() { + set_link(d, !on); + crate::panels::set_pstate(ui.ctx(), LINK_STATE, !on); + } +} + +/// Turn the link on or off. Turning it on keeps the proportions of the sizes the dialog shows. +fn set_link(d: &mut Dialog, on: bool) { + d.fields.insert(LINK.into(), json!(on)); + let (w, h) = (d.f64("width", 100.0), d.f64("height", 100.0)); + let ratio = h / w; + if on && ratio.is_finite() && ratio != 0.0 { + d.fields.insert(RATIO.into(), json!(ratio)); + } else { + // Off, or a side of zero: nothing to keep, so typing a side changes only that side. + d.fields.remove(RATIO); + } +} + +/// After the side `typed` ("width" or "height") was typed with the link on, set the other side +/// from the proportions the link keeps. +fn follow_link(d: &mut Dialog, typed: &str) { + let ratio = d.f64(RATIO, 0.0); + if !d.bool(LINK) || !ratio.is_finite() || ratio == 0.0 { + return; + } + let (other, v) = if typed == "width" { ("height", d.f64("width", 100.0) * ratio) } else { ("width", d.f64("height", 100.0) / ratio) }; + if v.is_finite() { + d.fields.insert(other.into(), json!(v)); + } +} + /// The top-left corner of a rectangle or ellipse: the click itself, or with Alt held (draw from centre) the click minus half the size. fn box_origin(d: &Dialog) -> (f64, f64) { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); @@ -402,6 +484,101 @@ mod tests { assert_eq!(box_origin(&fields(true)), (80.0, 40.0)); } + #[test] + fn the_link_keeps_the_proportions_it_had_when_turned_on() { + let mut d = Dialog::new("rectangle", json!({"x": 0.0, "y": 0.0, "width": "40 pt", "height": "20 pt"})); + // Off: a typed side changes only itself. + set_link(&mut d, false); + d.fields.insert("width".into(), json!(80.0)); + follow_link(&mut d, "width"); + assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (80.0, 20.0)); + // On at 80 × 20: each side follows the other at 4 : 1, also through a zero and back. + set_link(&mut d, true); + for (typed, value, expected) in + [("width", 100.0, (100.0, 25.0)), ("height", 50.0, (200.0, 50.0)), ("width", 0.0, (0.0, 0.0)), ("width", 40.0, (40.0, 10.0))] + { + d.fields.insert(typed.into(), json!(value)); + follow_link(&mut d, typed); + assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), expected, "{typed} = {value}"); + } + // A side of zero when the link turns on leaves nothing to keep: no NaN, no change. + let mut flat = Dialog::new("ellipse", json!({"width": 0.0, "height": 30.0})); + set_link(&mut flat, true); + flat.fields.insert("height".into(), json!(60.0)); + follow_link(&mut flat, "height"); + assert_eq!((flat.f64("width", -1.0), flat.f64("height", -1.0)), (0.0, 60.0)); + } + + /// The real dialog: Width comes before Height, the link is a named button that turns on with a + /// click, and the next Rectangle dialog opens with it on. + #[test] + fn the_rectangle_dialog_shows_width_then_height_and_a_link_button() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + const LINK_NAME: &str = "Constrain Width and Height Proportions"; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h.check_accessibility(); + let (w, ht) = (h.get_by_role_and_label(Role::TextInput, "Width:").rect(), h.get_by_role_and_label(Role::TextInput, "Height:").rect()); + assert!(w.center().y < ht.center().y, "Width is above Height: {w:?} {ht:?}"); + assert!(!h.state().ui.dialog.as_ref().unwrap().bool(LINK)); + let link = h.get_by_role_and_label(Role::Button, LINK_NAME).rect(); + assert!(link.left() > w.right() && link.top() < ht.center().y && link.bottom() > w.center().y, "the link sits beside both rows: {link:?}"); + h.get_by_role_and_label(Role::Button, LINK_NAME).click(); + h.run_steps(2); + let d = h.state().ui.dialog.as_ref().unwrap(); + assert!(d.bool(LINK)); + assert_eq!(d.f64(RATIO, 0.0), 1.0); + // Cancel, then click again: the link is still on. + h.state_mut().ui.dialog = None; + h.run_steps(2); + super::super::open_tool_dialog(h.state_mut(), "rectangle", json!({"x": 10.0, "y": 10.0})); + h.run_steps(2); + assert!(h.state().ui.dialog.as_ref().unwrap().bool(LINK)); + } + + /// With the link on, typing a width over a 40 × 20 rectangle's and pressing Enter draws it + /// 80 × 40. + #[test] + fn typing_a_width_with_the_link_on_draws_the_shape_in_proportion() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!(40.0)); + d.fields.insert("height".into(), json!(20.0)); + d.fields.insert(LINK.into(), json!(true)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("80 pt".into())]); + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + let b = doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!((b.width() - 80.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + } + #[test] fn a_negative_line_length_draws_and_keeps_the_same_direction() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); From 69d684ed36e56c60d9a48b1edf9f534b861ff326 Mon Sep 17 00:00:00 2001 From: nevarem <349680237+nevarem@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:45:19 +0800 Subject: [PATCH 2/2] Shapes: the size dialogs' link applies every change of its state The link applied its state only when its button was clicked, so turning it off and on again through `ui.dialog.set` kept the old proportions, and the next dialog didn't open with the state set that way. A link turned on at a side of zero also took new proportions on the next frame once that side was a size again. The dialog now keeps the link state it last applied and applies any change from it, however it was made: turned on, the link keeps the sizes the dialog shows then; turned off, it forgets them; either way the next dialog opens with the new state. Nothing else recalculates the proportions, so a link turned on at a side of zero stays unconstrained until it is turned off and on again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EjfPR4a1jPek7r3nyFFNYv --- crates/ui-egui/src/dialogs/shapes.rs | 120 ++++++++++++++++++++------- 1 file changed, 90 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index ea241ad8f..dd2201de0 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -261,6 +261,10 @@ const LINKED: [&str; 3] = ["rectangle", "roundedRectangle", "ellipse"]; /// The dialog field holding the link's state (UI-only, as every `__` field is). const LINK: &str = "__constrain"; +/// The dialog field holding the link state the dialog last applied ([`link_changed`]): a change of +/// [`LINK`], by its button or from outside (`ui.dialog.set`), shows as a difference from it. +const APPLIED: &str = "__constrainApplied"; + /// The dialog field holding height ÷ width while the link is on: the proportions it keeps. const RATIO: &str = "__ratio"; @@ -273,11 +277,9 @@ const LINK_STATE: &str = "shape-size-link"; /// opens with the link as this one left it. fn linked_size_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { if !d.fields.contains_key(LINK) { - set_link(d, crate::panels::pstate(ui.ctx(), LINK_STATE)); - } else if d.bool(LINK) && !d.fields.contains_key(RATIO) { - // Turned on from outside the dialog (`ui.dialog.set`): keep the sizes it shows now. - set_link(d, true); + d.fields.insert(LINK.into(), json!(crate::panels::pstate::(ui.ctx(), LINK_STATE))); } + sync_link(ui.ctx(), d); const BUTTON: f32 = 22.0; let mut rows: Option = None; egui::Grid::new("shape-size-dlg").num_columns(3).spacing([10.0, 8.0]).show(ui, |ui| { @@ -301,22 +303,36 @@ fn linked_size_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let resp = ui.interact(rect, ui.scope_id().with("shape-size-link"), egui::Sense::click()); let icon = if on { "link" } else { "link-2-off" }; if widgets::paint_icon_button(ui, resp, icon, "Constrain Width and Height Proportions", on).clicked() { - set_link(d, !on); - crate::panels::set_pstate(ui.ctx(), LINK_STATE, !on); + d.fields.insert(LINK.into(), json!(!on)); + sync_link(ui.ctx(), d); } } -/// Turn the link on or off. Turning it on keeps the proportions of the sizes the dialog shows. -fn set_link(d: &mut Dialog, on: bool) { - d.fields.insert(LINK.into(), json!(on)); - let (w, h) = (d.f64("width", 100.0), d.f64("height", 100.0)); - let ratio = h / w; +/// Apply a change of the link's state, however it was made ([`link_changed`]), and keep the new +/// state for the next of these dialogs. +fn sync_link(ctx: &egui::Context, d: &mut Dialog) { + if let Some(on) = link_changed(d) { + crate::panels::set_pstate(ctx, LINK_STATE, on); + } +} + +/// 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 sizes the dialog shows +/// then; turned off, it forgets them. A side 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)); + let ratio = d.f64("height", 100.0) / d.f64("width", 100.0); if on && ratio.is_finite() && ratio != 0.0 { d.fields.insert(RATIO.into(), json!(ratio)); } else { - // Off, or a side of zero: nothing to keep, so typing a side changes only that side. d.fields.remove(RATIO); } + Some(on) } /// After the side `typed` ("width" or "height") was typed with the link on, set the other side @@ -484,29 +500,65 @@ mod tests { assert_eq!(box_origin(&fields(true)), (80.0, 40.0)); } + /// Turn the link on or off as its button or `ui.dialog.set` does, then apply it as the form + /// does on its next frame; what [`link_changed`] returned. + fn turn(d: &mut Dialog, on: bool) -> Option { + d.fields.insert(LINK.into(), json!(on)); + link_changed(d) + } + + fn type_size(d: &mut Dialog, typed: &str, value: f64) -> (f64, f64) { + d.fields.insert(typed.into(), json!(value)); + follow_link(d, typed); + // The form applies the link's state again on every frame: an unchanged state changes nothing. + assert_eq!(link_changed(d), None); + (d.f64("width", -1.0), d.f64("height", -1.0)) + } + #[test] fn the_link_keeps_the_proportions_it_had_when_turned_on() { let mut d = Dialog::new("rectangle", json!({"x": 0.0, "y": 0.0, "width": "40 pt", "height": "20 pt"})); // Off: a typed side changes only itself. - set_link(&mut d, false); - d.fields.insert("width".into(), json!(80.0)); - follow_link(&mut d, "width"); - assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (80.0, 20.0)); + assert_eq!(turn(&mut d, false), Some(false)); + assert_eq!(type_size(&mut d, "width", 80.0), (80.0, 20.0)); // On at 80 × 20: each side follows the other at 4 : 1, also through a zero and back. - set_link(&mut d, true); + assert_eq!(turn(&mut d, true), Some(true)); for (typed, value, expected) in [("width", 100.0, (100.0, 25.0)), ("height", 50.0, (200.0, 50.0)), ("width", 0.0, (0.0, 0.0)), ("width", 40.0, (40.0, 10.0))] { - d.fields.insert(typed.into(), json!(value)); - follow_link(&mut d, typed); - assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), expected, "{typed} = {value}"); + assert_eq!(type_size(&mut d, typed, value), expected, "{typed} = {value}"); } - // A side of zero when the link turns on leaves nothing to keep: no NaN, no change. - let mut flat = Dialog::new("ellipse", json!({"width": 0.0, "height": 30.0})); - set_link(&mut flat, true); - flat.fields.insert("height".into(), json!(60.0)); - follow_link(&mut flat, "height"); - assert_eq!((flat.f64("width", -1.0), flat.f64("height", -1.0)), (0.0, 60.0)); + } + + /// Turned off and on again from outside the dialog (`ui.dialog.set`), the link forgets the old + /// proportions and keeps the ones the dialog shows when it comes back on. + #[test] + fn the_link_follows_changes_made_from_outside_the_dialog() { + let mut d = Dialog::new("rectangle", json!({"width": 80.0, "height": 20.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(turn(&mut d, false), Some(false)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 30.0), (30.0, 20.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (30.0, 60.0)); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(type_size(&mut d, "width", 10.0), (10.0, 20.0)); + // Set to what it already is: nothing to apply, the proportions stay. + assert_eq!(turn(&mut d, true), None); + assert_eq!(type_size(&mut d, "height", 40.0), (20.0, 40.0)); + } + + /// A side of zero when the link turns on leaves nothing to keep (no NaN): typing changes only + /// the side typed, even once both sides are sizes again, until the link is turned on again. + #[test] + fn a_link_turned_on_at_a_zero_side_stays_unconstrained_until_turned_on_again() { + let mut d = Dialog::new("ellipse", json!({"width": 0.0, "height": 30.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 50.0), (50.0, 30.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (50.0, 60.0)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!((turn(&mut d, false), turn(&mut d, true)), (Some(false), Some(true))); + assert_eq!(type_size(&mut d, "width", 100.0), (100.0, 120.0)); } /// The real dialog: Width comes before Height, the link is a named button that turns on with a @@ -543,11 +595,19 @@ mod tests { assert!(d.bool(LINK)); assert_eq!(d.f64(RATIO, 0.0), 1.0); // Cancel, then click again: the link is still on. - h.state_mut().ui.dialog = None; + let reopen = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| { + h.state_mut().ui.dialog = None; + h.run_steps(2); + super::super::open_tool_dialog(h.state_mut(), "rectangle", json!({"x": 10.0, "y": 10.0})); + h.run_steps(2); + h.state().ui.dialog.as_ref().unwrap().bool(LINK) + }; + assert!(reopen(&mut h)); + // Turned off from outside the dialog (`ui.dialog.set`): the next dialog opens with it off. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(LINK.into(), json!(false)); h.run_steps(2); - super::super::open_tool_dialog(h.state_mut(), "rectangle", json!({"x": 10.0, "y": 10.0})); - h.run_steps(2); - assert!(h.state().ui.dialog.as_ref().unwrap().bool(LINK)); + assert!(!h.state().ui.dialog.as_ref().unwrap().fields.contains_key(RATIO)); + assert!(!reopen(&mut h)); } /// With the link on, typing a width over a 40 × 20 rectangle's and pressing Enter draws it