From e461b8b81886c32324a972251d34b5f66b2f5ec4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 10:47:44 -0700 Subject: [PATCH] Fields: Shift+Enter applies a pattern, layer, artboard or asset name and keeps the field active Shift+Enter (Shift+Return) in the Pattern Options panel's Name field and in the name fields that a double-click opens in the Layers, Artboards and Asset Export panels ended the editing, as Enter does. These fields draw their own text box and do not use the shared field widgets that #2431 changed. - Each of them now calls widgets::own_field_shift_enter, built on #2431's shift_enter_keeps_focus and after_shift_enter. Shift+Enter applies the name as Enter does and the field keeps the focus. In the next frame the field shows the name as applied, all selected, so typing replaces it. - Enter, Escape and a click elsewhere work as before. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/artboards.rs | 37 +++++++++++++++++++ crates/ui-egui/src/panels/asset_export.rs | 11 +++++- crates/ui-egui/src/panels/layers.rs | 37 ++++++++++++++++++- crates/ui-egui/src/panels/pattern_options.rs | 35 +++++++++++++++++- .../ui-egui/src/panels/tests_asset_export.rs | 29 +++++++++++++++ crates/ui-egui/src/widgets.rs | 14 +++++++ docs/target-app-parity.md | 1 + 7 files changed, 159 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index d1de35d56..7e8a4c2da 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -306,6 +306,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if editing == Some(i) { let id = ui.id().with(("ab-name", i)); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); + let kept = widgets::own_field_shift_enter(ui, id, &mut buf, name); let te = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(id).font(egui::FontId::proportional(12.0))); if !te.has_focus() && !te.lost_focus() { te.request_focus(); @@ -317,6 +318,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name { app.run("artboard.setProps", json!({"index": i, "name": buf.trim()})).ok(); } + } else if kept && !buf.trim().is_empty() && buf != *name { + // Shift+Enter renames it and keeps the field open. + app.run("artboard.setProps", json!({"index": i, "name": buf.trim()})).ok(); } } else { ui.painter().text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text); @@ -660,6 +664,39 @@ mod tests { assert_eq!((app.session.tool_options()["active"].clone(), selected(&app, 2)), (json!(0), 0)); } + /// Shift+Enter in a row's name field renames the artboard and keeps the field open, the new + /// name selected; Enter then renames it again and closes the field. + #[test] + fn shift_enter_renames_an_artboard_and_keeps_the_field_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 400, "y": 300, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let name = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards[1].name.clone(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + // What a double-click on the second row's name does: its name field opens. + set_pstate::>(&ctx, "ab-edit", Some(1)); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Cover".into())]); + frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)]); + assert_eq!(name(&app), "Cover", "Shift+Enter applies the name"); + let field = ctx.memory(|m| m.focused()).expect("the field keeps the focus"); + assert_eq!(pstate::>(&ctx, "ab-edit"), Some(1), "and stays open"); + frame(&mut app, vec![]); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 5)), "the name shown is selected"); + frame(&mut app, vec![egui::Event::Text("Back".into())]); + frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + frame(&mut app, vec![]); + assert_eq!(name(&app), "Back", "typing replaced it, and Enter applies that"); + assert_eq!(pstate::>(&ctx, "ab-edit"), None, "and closes the field"); + } + /// A row's options icon opens Artboard Options for that row's artboard and leaves the tool as /// it is (#1876): a click on the active artboard's icon, or a double-click on another row's /// icon, whose first click only makes that artboard active. With the Artboard tool in use, the diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 115d2e7f4..006673d32 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -126,15 +126,22 @@ fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { match &renaming { Some((id, buf)) if *id == a.id => { let mut buf = buf.clone(); + let field = rename_id.with("field"); + let kept = widgets::own_field_shift_enter(ui, field, &mut buf, &a.name); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); - let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(egui::FontId::proportional(11.5))); - // Enter or a click elsewhere commits, Escape cancels. + let te = child + .add(egui::TextEdit::singleline(&mut buf).id(field).desired_width(name_rect.width()).font(egui::FontId::proportional(11.5))); + // Enter or a click elsewhere commits, Escape cancels; Shift+Enter commits and + // keeps the field open. if te.lost_focus() { ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf.trim() != a.name && !buf.trim().is_empty() { ev.rename = Some((a.id, buf.trim().to_string())); } } else { + if kept && buf.trim() != a.name && !buf.trim().is_empty() { + ev.rename = Some((a.id, buf.trim().to_string())); + } te.request_focus(); ui.data_mut(|d| d.insert_temp(rename_id, (a.id, buf))); } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index df9d00d76..b7974e6c3 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -953,8 +953,10 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { let name_end; match renaming { Some((rid, mut buf)) if rid == n.id.0 => { + let field = rename_id().with("field"); + let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); - let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(font.clone())); + let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).desired_width(name_rect.width()).font(font.clone())); // Focus the field as it opens. Asking every frame took the focus back from Enter, // Escape or a click elsewhere, so the rename never ended. if !te.has_focus() && !te.lost_focus() { @@ -966,6 +968,10 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); } } else { + // Shift+Enter renames it and keeps the field open. + if kept && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); + } ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf))); } name_end = name_rect.right(); @@ -2018,6 +2024,35 @@ mod tests { assert_eq!(name(&app), "Sky", "Escape cancels"); } + /// Shift+Enter in the name field renames the layer and keeps the field open, the new name + /// selected; Enter then renames it again and closes the field. + #[test] + fn shift_enter_renames_a_layer_and_keeps_the_field_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); + let ctx = egui::Context::default(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let field = rename_id().with("field"); + ctx.data_mut(|d| d.insert_temp(rename_id(), (layer.0, name(&app)))); + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Sky".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)], false); + assert_eq!(name(&app), "Sky", "Shift+Enter applies the name"); + assert!(ctx.memory(|m| m.has_focus(field)), "the field keeps the focus"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_some(), "and stays open"); + frame(&mut app, &ctx, vec![], false); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 3)), "the name shown is selected"); + frame(&mut app, &ctx, vec![egui::Event::Text("Sea".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sea", "typing replaced it, and Enter applies that"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id())).is_none(), "and closes the field"); + } + #[test] fn an_open_rename_does_not_follow_into_another_document() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index 6a9c348d2..bde462c9d 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -43,16 +43,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let name = def.name.clone(); - // Name (commit on Enter / focus loss). + // Name (commit on Enter / focus loss; Shift+Enter commits and keeps the focus). row(ui, tl!("Name:"), |ui| { let id = ui.id().with("po-name"); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); if !ui.memory(|m| m.has_focus(id)) { buf = name.clone(); } + let kept = widgets::own_field_shift_enter(ui, id, &mut buf, &name); let r = ui.add(egui::TextEdit::singleline(&mut buf).id(id).desired_width(150.0)); ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if r.lost_focus() && !buf.trim().is_empty() && buf != name { + if (r.lost_focus() || kept) && !buf.trim().is_empty() && buf != name { set(app, &name, json!({"newName": buf.trim()})); } }); @@ -231,6 +232,36 @@ mod tests { v } + /// Shift+Enter in the Name field renames the pattern and keeps the field active, the new name + /// selected; Enter then renames it again and ends the editing. + #[test] + fn shift_enter_in_the_name_field_renames_the_pattern_and_keeps_the_focus() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let named = |app: &VectorcraftApp, name: &str| app.session.active().unwrap().doc.pattern(name).is_some(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let texts = frame(&mut app, &ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == "Dots").expect("the Name field").1.center(); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + frame(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Stripes".into())]); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)]); + assert!(named(&app, "Stripes"), "Shift+Enter applies the name"); + let field = ctx.memory(|m| m.focused()).expect("the field keeps the focus"); + frame(&mut app, &ctx, vec![]); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 7)), "the name shown is selected"); + frame(&mut app, &ctx, vec![egui::Event::Text("Waves".into())]); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert!(named(&app, "Waves"), "typing replaced it, and Enter applies that"); + assert!(ctx.memory(|m| m.focused()).is_none(), "Enter ends the editing"); + } + #[test] fn show_swatch_bounds_toggles_the_pattern_option() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index ace9b6cc2..51d4d9cc0 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -161,6 +161,35 @@ fn renaming_an_asset_runs_assets_rename() { assert_eq!(assets(&mut app)[0]["name"], "Logo"); } +/// Shift+Enter in an asset's name field renames it and keeps the field open, the new name +/// selected; Enter then renames it again and closes the field. +#[test] +fn shift_enter_renames_an_asset_and_keeps_the_field_open() { + let (mut app, _, [a, _]) = app(false); + let id = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rename_id = egui::Id::new("asset-export-rename"); + let field = rename_id.with("field"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&ctx, &mut app, vec![], false); + // What a double-click on the tile does: its name field opens with the name. + ctx.data_mut(|d| d.insert_temp(rename_id, (id, "Asset 1".to_string()))); + frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Logo".into())], false); + frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::SHIFT)], false); + assert_eq!(assets(&mut app)[0]["name"], "Logo", "Shift+Enter applies the name"); + assert!(ctx.memory(|m| m.has_focus(field)), "the field keeps the focus"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id)).is_some(), "and stays open"); + frame(&ctx, &mut app, vec![], false); + let selected = egui::TextEdit::load_state(&ctx, field).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 4)), "the name shown is selected"); + frame(&ctx, &mut app, vec![egui::Event::Text("Mark".into())], false); + frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], false); + assert_eq!(assets(&mut app)[0]["name"], "Mark", "typing replaced it, and Enter applies that"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(rename_id)).is_none(), "and closes the field"); +} + #[test] fn the_rows_are_the_shared_export_settings() { let (mut app, log, [a, b]) = app(true); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f358ff2c8..c9e566c22 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -423,6 +423,20 @@ fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) } +/// Handles Shift+Enter in a single-line panel field that draws its own text box `id` (Pattern +/// Options' Name, the inline renames in the Layers, Artboards and Asset Export panels). Call it +/// before the box is drawn, with `buf` the text the box will show and `shown` the value as it is +/// now. When the field took Shift+Enter in the last frame, `buf` becomes `shown`, all of it +/// selected. Returns whether Shift+Enter was pressed in this frame ([`shift_enter_keeps_focus`]): +/// the field then applies `buf` as Enter does and keeps the focus. +pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, shown: &str) -> bool { + if after_shift_enter(ui, id) { + *buf = shown.to_string(); + select_all(ui, id, buf); + } + shift_enter_keeps_focus(ui, id) +} + /// Numeric fields select the whole text on keyboard focus, a completed focus click or a /// double-click. A pointer press keeps its caret so dragging selects from the pressed position, /// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 06aa47310..8d77929d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -481,6 +481,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. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. + - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. - 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. 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. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - 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.