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] 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());