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.
This commit is contained in:
dotCooCoo
2026-10-16 14:58:09 +07:00
committed by bflatastic
parent 03ffe790be
commit 467c47b719
6 changed files with 128 additions and 19 deletions
+8 -2
View File
@@ -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();
}
}
+3 -3
View File
@@ -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();
+84 -7
View File
@@ -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();
+13 -6
View File
@@ -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 })) {
+19
View File
@@ -317,6 +317,25 @@ pub(crate) fn enter_modifiers(ui: &Ui) -> Option<egui::Modifiers> {
})
}
/// 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<f64>, unit: Unit, width: f32) -> Option<f64> {
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();
+1 -1
View File
@@ -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.