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.