From bbad808a02d801a5388cc72f1f3dcb29c7b8bf5f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 07:26:47 -0700 Subject: [PATCH] Transform: Alt+Enter applies a value to a copy of the selection In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) in X, Y, W, H, Rotate or Shear ended editing as Enter does, and the selection itself moved, resized, turned or sheared. Now the field's command gets `copy: true` when the Enter that committed it was pressed with Alt. The copy takes the value, the original stays where it was, and the copy is selected, in one undo step. A press elsewhere with Alt held applies the value without a copy. With anchor points selected with Direct Selection, Alt+Enter applies the value to them, as Enter does. - widgets::enter_modifiers returns the modifiers of the frame's Enter press. - transform::alt_enter_copies gives the `copy` param of the three surfaces' commands. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/chrome.rs | 3 +- crates/ui-egui/src/panels/properties.rs | 11 +- crates/ui-egui/src/panels/tests_constrain.rs | 106 +++++++++++++++++-- crates/ui-egui/src/panels/transform.rs | 20 ++-- crates/ui-egui/src/widgets.rs | 11 ++ docs/target-app-parity.md | 1 + 6 files changed, 133 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8b40ae8ac..f202111fa 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -346,6 +346,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 copy = crate::panels::transform::alt_enter_copies(app, ui); for (k, lbl, v, offset) in [ ("x", "X:", c.x, origin.x), ("y", "Y:", c.y, origin.y), @@ -358,7 +359,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut 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) { - app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link})).ok(); + 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 4375e610a..c67d38f98 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -415,6 +415,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { let b = bx.rect; let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); let rp = bx.reference_point(refi); + let copy = super::transform::alt_enter_copies(app, ui); section_header(ui, tl!("Transform")); let fw = super::field_width(ui); ui.horizontal(|ui| { @@ -426,20 +427,20 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut 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})).ok(); + 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) { - app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); + app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); dim_label(ui, "Y:"); if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y - origin.y), units, fw) { - app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok(); + 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) { - app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); + app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); }); @@ -451,7 +452,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { crate::scrub::note_label(ui, icon.rect); // The bounding box's angle: a new value turns the selection to it. if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, fw) { - app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); + app.run("object.rotate", json!({"angle": a, "absolute": true, "copy": copy})).ok(); } ui.add_space(10.0); // Same mapping as the Transform panel (#1910): Lucide flip-vertical-2 is the vertical diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index c074b3ff4..7c37acc0f 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -1,5 +1,6 @@ //! 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`. use egui::{Event, Key, Modifiers, PointerButton}; use serde_json::json; @@ -25,8 +26,21 @@ fn size(app: &VectorcraftApp) -> (f64, f64) { (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) { +/// How a field's editing ends: Enter, or a press away from the field, with these modifiers held. +#[derive(Clone, Copy)] +enum End { + Enter(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); +} + +/// [`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) { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut time = 0.0; @@ -35,14 +49,43 @@ fn set_width(app: &mut VectorcraftApp, draw: Draw, value: &str) { 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)]); + 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 enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - frame(app, vec![enter]); + // 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::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()); + } + } +} + +/// 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) { + set_field(app, draw, "100 pt", value, End::Enter(Modifiers::NONE)); +} + +/// The paths of the document, bottom to top: their size and angle (rounded), and whether each is +/// selected. +fn paths(app: &VectorcraftApp) -> Vec<((f64, f64), f64, bool)> { + let st = app.session.active().unwrap(); + let mut out = vec![]; + st.doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) { + let b = st.doc.oriented_bounds(&[n.id], false).unwrap(); + out.push(((b.rect.width().round(), b.rect.height().round()), b.angle.round(), st.selection.objects.contains(&n.id))); + } + }); + out } #[test] @@ -62,6 +105,55 @@ fn the_link_keeps_proportions_in_every_size_field() { } } +/// Alt+Enter (Option+Return) applies a field's value to a copy of the selection and selects the +/// copy, in one undo step; a press elsewhere with Alt held applies it to the selection. +#[test] +fn alt_enter_applies_the_value_to_a_copy_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 { + let mut app = rect_app(); + set_field(&mut app, draw, "100 pt", "200", End::Enter(Modifiers::ALT)); + assert_eq!(paths(&app), [((100.0, 50.0), 0.0, false), ((200.0, 50.0), 0.0, true)], "{name}"); + run(&mut app, "edit.undo", json!({})); + assert_eq!(paths(&app), [((100.0, 50.0), 0.0, true)], "{name}, undone"); + let mut app = rect_app(); + set_field(&mut app, draw, "100 pt", "200", End::ClickAway(Modifiers::ALT)); + assert_eq!(paths(&app), [((200.0, 50.0), 0.0, true)], "{name}, a press away"); + } +} + +/// Alt+Enter in Rotate turns a copy to the angle and leaves the original, in the Transform panel +/// and the Properties panel; in the Transform panel, Alt+Enter in Shear shears a copy. +#[test] +fn alt_enter_in_rotate_and_shear_transforms_a_copy() { + for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section)] { + let mut app = rect_app(); + // Rotate is the first field showing 0° (the Transform panel's Shear, after it, rests at 0° too). + set_field(&mut app, draw, "0°", "45", End::Enter(Modifiers::ALT)); + assert_eq!(paths(&app), [((100.0, 50.0), 0.0, false), ((100.0, 50.0), 45.0, true)], "{name}"); + } + let mut app = rect_app(); + // The second field showing 0° is Shear. + set_nth_field(&mut app, transform::show, ("0°", 1), "30", End::Enter(Modifiers::ALT)); + assert_eq!(paths(&app), [((100.0, 50.0), 0.0, false), ((129.0, 50.0), 0.0, true)]); +} + +/// With anchors selected with Direct Selection, the fields show those anchors' box, and Alt+Enter +/// applies the value to them as Enter does. +#[test] +fn alt_enter_moves_selected_anchors_without_a_copy() { + let mut app = rect_app(); + let id = app.session.active().unwrap().selection.objects[0].0; + run(&mut app, "select.anchors", json!({"id": id, "anchors": [[0, 1]]})); + let before = paths(&app).len(); + // The anchor's X (the top-right corner of the rectangle at 10, 10). + set_field(&mut app, transform::show, "110 pt", "150", End::Enter(Modifiers::ALT)); + assert_eq!(paths(&app).len(), before); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.bounds_of(&st.selection.objects, false).unwrap().width().round(), 140.0); +} + #[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 ae9841e78..35a0c13d6 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -150,6 +150,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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 copy = alt_enter_copies(app, ui); let has = bounds.is_some(); let rp = bx.map(|b| b.reference_point(refi)); ui.horizontal(|ui| { @@ -161,20 +162,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { widgets::dim_label(ui, "X:"); if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x - origin.x), units, 80.0) { - app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok(); + 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) { - app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); + app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); widgets::dim_label(ui, "Y:"); if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y - origin.y), units, 80.0) { - app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok(); + 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) { - app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); + app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); }); @@ -192,7 +193,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) && a != angle { - app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok(); + app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin, "copy": copy})).ok(); } ui.add_space(4.0); let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear")); @@ -202,7 +203,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && a != 0.0 && a.abs() < 90.0 { - app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin})).ok(); + app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin, "copy": copy})).ok(); } }); }); @@ -340,6 +341,13 @@ pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The `copy` param of a command that a field of the Transform panel, the Properties panel's +/// Transform section or the Control bar runs: true when Alt+Enter (Option+Return) applied the value +/// and no anchors are selected with Direct Selection. +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()) +} + /// 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 de2253709..2f3af5e8d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -315,6 +315,17 @@ pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) { ui.memory_mut(|m| m.request_focus(id)); } +/// The modifiers held with an Enter press among this frame's events, `None` without one, as when a +/// field's editing ends with a click elsewhere. +pub(crate) fn enter_modifiers(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) +} + /// 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 bdc7ff15a..6f5d7f207 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -428,6 +428,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. - 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.