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.
This commit is contained in:
dotCooCoo
2026-10-16 02:26:01 +07:00
committed by bflatastic
parent d68e14a393
commit bbad808a02
6 changed files with 133 additions and 19 deletions
+2 -1
View File
@@ -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();
}
}
}
+6 -5
View File
@@ -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
+99 -7
View File
@@ -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();
+14 -6
View File
@@ -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 })) {
+11
View File
@@ -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<egui::Modifiers> {
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();
+1
View File
@@ -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.