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