From 467c47b71984347e38ba4f366cd1d745a9fc0a58 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 14:59:39 -0700 Subject: [PATCH] Transform: Ctrl/Cmd+Enter in W or H scales the selection proportionally In the Transform panel, the Properties panel's Transform section and the Control bar, Ctrl+Enter (Cmd+Return on a Mac) in W or H left the field active and applied nothing. Now it applies the value as Enter does and sends `proportional: true` to `object.setBounds`, so the other side scales by the same factor with the Constrain Width and Height Proportions link off. With the Artboard tool, the Transform panel's W and H keep the active artboard's proportions the same way. X, Y, Rotate, Shear and the number fields of dialogs keep Ctrl/Cmd+Enter as it was. - widgets::size_field is a num_field that Ctrl/Cmd+Enter also commits. - transform::proportional gives the `proportional` param of the W and H fields: the link, or true after Ctrl/Cmd+Enter. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/chrome.rs | 10 ++- crates/ui-egui/src/panels/properties.rs | 6 +- crates/ui-egui/src/panels/tests_constrain.rs | 91 ++++++++++++++++++-- crates/ui-egui/src/panels/transform.rs | 19 ++-- crates/ui-egui/src/widgets.rs | 19 ++++ docs/target-app-parity.md | 2 +- 6 files changed, 128 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6d90b3e5d..c1eb9cb43 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -353,7 +353,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { { let c = b.center(); let origin = app.ruler_origin(); - let link = app.session.prefs.constrain_proportions; + let link = crate::panels::transform::proportional(app, ui); let copy = crate::panels::transform::alt_enter_copies(app, ui); for (k, lbl, v, offset) in [ ("x", "X:", c.x, origin.x), @@ -366,7 +366,13 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { crate::panels::transform::constrain_link(app, ui); } widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); - if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v - offset), units, 80.0) { + let (id, shown) = (("cb", k), Some(v - offset)); + let set = if matches!(k, "width" | "height") { + widgets::size_field(ui, id, shown, units, 80.0) + } else { + widgets::num_field(ui, id, shown, units, 80.0) + }; + if let Some(nv) = set { app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9bc1985c0..3a39b47d2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -436,14 +436,14 @@ 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 link = app.session.prefs.constrain_proportions; + let link = super::transform::proportional(app, ui); 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 - origin.x), units, fw) { app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi, "copy": copy})).ok(); } dim_label(ui, "W:"); - if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) { + if let Some(v) = widgets::size_field(ui, "tw", Some(b.width()), units, fw) { app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); @@ -452,7 +452,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi, "copy": copy})).ok(); } dim_label(ui, "H:"); - if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) { + if let Some(v) = widgets::size_field(ui, "th", Some(b.height()), units, fw) { app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index 7c37acc0f..ad0e7976e 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -1,6 +1,7 @@ //! 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`. -//! Alt+Enter in their fields applies the value to a copy of the selection, passed on as `copy`. +//! Alt+Enter in their fields applies the value to a copy of the selection, passed on as `copy`, and +//! Ctrl/Cmd+Enter in W or H keeps the proportions whether the link is on or off. use egui::{Event, Key, Modifiers, PointerButton}; use serde_json::json; @@ -26,21 +27,23 @@ fn size(app: &VectorcraftApp) -> (f64, f64) { (b.width().round(), b.height().round()) } -/// How a field's editing ends: Enter, or a press away from the field, with these modifiers held. +/// How a field's editing ends: Enter, Enter in the frame the text is typed in, or a press away from +/// the field, with these modifiers held. #[derive(Clone, Copy)] enum End { Enter(Modifiers), + TypedEnter(Modifiers), ClickAway(Modifiers), } /// Type `value` into the field of what `draw` shows that shows `field` (the first one drawn), and -/// end its editing with `end`. -fn set_field(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str, end: End) { - set_nth_field(app, draw, (field, 0), value, end); +/// end its editing with `end`. Returns the context the frames ran in. +fn set_field(app: &mut VectorcraftApp, draw: Draw, field: &str, value: &str, end: End) -> egui::Context { + set_nth_field(app, draw, (field, 0), value, end) } /// [`set_field`] for the field drawn `nth` (from 0) among those that show `field`. -fn set_nth_field(app: &mut VectorcraftApp, draw: Draw, (field, nth): (&str, usize), value: &str, end: End) { +fn set_nth_field(app: &mut VectorcraftApp, draw: Draw, (field, nth): (&str, usize), value: &str, end: End) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut time = 0.0; @@ -48,25 +51,35 @@ fn set_nth_field(app: &mut VectorcraftApp, draw: Draw, (field, nth): (&str, usiz time += 1.0; frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, draw) }; + // A window shows nothing in its first frame, which only measures it. + frame(app, vec![]); let shown = frame(app, vec![]); let at = shown.iter().filter(|(s, _)| s == field).nth(nth).unwrap_or_else(|| panic!("no {field} field {nth} in {shown:?}")).1.center(); let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; frame(app, vec![Event::PointerMoved(at), button(at, true, Modifiers::NONE), button(at, false, Modifiers::NONE)]); // Focus selected the whole field: typing replaces it. frame(app, vec![]); - frame(app, vec![Event::Text(value.into())]); + let text = Event::Text(value.into()); + if !matches!(end, End::TypedEnter(_)) { + frame(app, vec![text.clone()]); + } // The modifiers go down first, as a keyboard reports them. match end { End::Enter(modifiers) => { let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers }; frame(app, vec![Event::ModifiersChanged(modifiers), enter]); } + End::TypedEnter(modifiers) => { + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(app, vec![text, Event::ModifiersChanged(modifiers), enter]); + } End::ClickAway(modifiers) => { let away = at + egui::vec2(0.0, 300.0); let press = [button(away, true, modifiers), button(away, false, modifiers)]; frame(app, [Event::ModifiersChanged(modifiers), Event::PointerMoved(away)].into_iter().chain(press).collect()); } } + ctx } /// Type `value` into the W field (the one showing "100 pt") of what `draw` shows, and press Enter. @@ -154,6 +167,70 @@ fn alt_enter_moves_selected_anchors_without_a_copy() { assert_eq!(st.doc.bounds_of(&st.selection.objects, false).unwrap().width().round(), 140.0); } +/// Ctrl/Cmd+Enter (Command+Return) in W or H applies the value and keeps the proportions with the +/// link off, and the field's editing ends as with Enter. +#[test] +fn ctrl_cmd_enter_in_a_size_field_scales_proportionally() { + let surfaces: [(&str, Draw); 3] = + [("Transform panel", transform::show), ("Properties", properties::transform_section), ("Control bar", crate::chrome::control_bar)]; + for (name, draw) in surfaces { + for (field, value) in [("100 pt", "200"), ("50 pt", "100")] { + let mut app = rect_app(); + let ctx = set_field(&mut app, draw, field, value, End::Enter(Modifiers::COMMAND)); + assert_eq!(size(&app), (200.0, 100.0), "{name}, {field}"); + assert_eq!(ctx.memory(|m| m.focused()), None, "{name}, {field}: the field's editing ended"); + } + // The text typed in the frame of the Ctrl/Cmd+Enter is taken too. + let mut app = rect_app(); + set_field(&mut app, draw, "100 pt", "300", End::TypedEnter(Modifiers::COMMAND)); + assert_eq!(size(&app), (300.0, 150.0), "{name}, typed with the Ctrl/Cmd+Enter"); + } +} + +/// Ctrl/Cmd+Enter in X or Y does nothing: the field keeps the focus and the selection stays. +#[test] +fn ctrl_cmd_enter_in_a_position_field_keeps_editing() { + for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section)] { + let mut app = rect_app(); + // X of the rectangle's center. + let ctx = set_field(&mut app, draw, "60 pt", "200", End::Enter(Modifiers::COMMAND)); + assert_eq!(paths(&app), [((100.0, 50.0), 0.0, true)], "{name}"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.bounds_of(&st.selection.objects, false).unwrap().x0, 10.0, "{name}"); + assert!(ctx.memory(|m| m.focused().is_some()), "{name}: the field keeps the focus"); + } +} + +/// With the Artboard tool, Ctrl/Cmd+Enter in W keeps the active artboard's proportions. +#[test] +fn ctrl_cmd_enter_scales_the_artboard_proportionally() { + let board = |end| { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 150})); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + set_field(&mut app, transform::show, "200 pt", "400", end); + let r = app.session.active().unwrap().doc.artboards[0].rect; + (r.width().round(), r.height().round()) + }; + assert_eq!(board(End::Enter(Modifiers::NONE)), (400.0, 150.0)); + assert_eq!(board(End::Enter(Modifiers::COMMAND)), (400.0, 300.0)); +} + +/// A length field of the New Document dialog keeps the focus on Ctrl/Cmd+Enter, and the dialog +/// creates no document. +#[test] +fn ctrl_cmd_enter_in_a_dialog_field_keeps_editing() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + let unit = d.fields.get("units").and_then(|u| u.as_str()).and_then(vectorcraft_doc::Unit::named).unwrap_or_default(); + let shown = unit.format(d.fields["width"].as_f64().unwrap()); + let dialogs: Draw = |app, ui| crate::dialogs::show(app, ui.ctx()); + let ctx = set_field(&mut app, dialogs, &shown, "300", End::Enter(Modifiers::COMMAND)); + assert!(app.ui.dialog.is_some() && app.session.documents().is_empty()); + assert!(ctx.memory(|m| m.focused().is_some()), "the field keeps the focus"); +} + #[test] fn the_link_is_a_preference_agents_can_read_and_set() { let mut app = rect_app(); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 35a0c13d6..029b0a45e 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -55,7 +55,7 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) let units = app.session.general_unit(); let origin = app.ruler_origin(); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); - let link = app.session.prefs.constrain_proportions; + let link = proportional(app, ui); let rp = vectorcraft_geom::reference_point(r, refi); let set = |app: &mut VectorcraftApp, x, y, w, h| { let n = artboard_rect(r, refi, x, y, w, h, link); @@ -77,7 +77,7 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) set(app, Some(v + origin.x), None, None, None); } widgets::dim_label(ui, "W:"); - if let Some(v) = widgets::num_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) { + if let Some(v) = widgets::size_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) { set(app, None, None, Some(v), None); } ui.end_row(); @@ -86,7 +86,7 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) set(app, None, Some(v + origin.y), None, None); } widgets::dim_label(ui, "H:"); - if let Some(v) = widgets::num_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) { + if let Some(v) = widgets::size_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) { set(app, None, None, None, Some(v)); } ui.end_row(); @@ -149,7 +149,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 = app.session.prefs.constrain_proportions; + let link = proportional(app, ui); let copy = alt_enter_copies(app, ui); let has = bounds.is_some(); let rp = bx.map(|b| b.reference_point(refi)); @@ -165,7 +165,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi, "copy": copy})).ok(); } widgets::dim_label(ui, "W:"); - if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) { + if let Some(v) = widgets::size_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) { app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); @@ -174,7 +174,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi, "copy": copy})).ok(); } widgets::dim_label(ui, "H:"); - if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) { + if let Some(v) = widgets::size_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) { app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); @@ -348,6 +348,13 @@ pub fn alt_enter_copies(app: &VectorcraftApp, ui: &Ui) -> bool { widgets::enter_modifiers(ui).is_some_and(|m| m.alt) && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()) } +/// The `proportional` param of the W and H fields of the Transform panel (the artboard's too), the +/// Properties panel's Transform section and the Control bar: the Constrain Width and Height +/// Proportions link, or true when Ctrl/Cmd+Enter applied the value. +pub fn proportional(app: &VectorcraftApp, ui: &Ui) -> bool { + app.session.prefs.constrain_proportions || widgets::enter_modifiers(ui).is_some_and(|m| m.command) +} + /// 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/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4d0f2774e..945c2c3d7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -317,6 +317,25 @@ pub(crate) fn enter_modifiers(ui: &Ui) -> Option { }) } +/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter +/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. +pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { + use egui::{Event, Key}; + // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right + // after it, so the box first takes the text typed before it in this frame. + let field = ui.id().with(&id); + if ui.memory(|m| m.has_focus(field)) { + ui.input_mut(|i| { + let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); + if let Some(at) = i.events.iter().position(cmd_enter) { + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + i.events.insert(at + 1, enter); + } + }); + } + num_field(ui, id, value, unit, width) +} + /// Select all of `text` in text field `id`. fn select_all(ui: &Ui, id: egui::Id, text: &str) { let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49a87bff5..7802ffd20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -443,7 +443,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. + - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.