diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5944a7a66..43e274505 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -607,6 +607,7 @@ pub(crate) fn opened_by_panel(app: &mut VectorcraftApp) { /// Draws the active dialog and handles shared initial focus, keyboard shortcuts and actions. /// Inline actions and footer buttons use the same confirmation and preview rollback paths. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + crate::shortcuts::hold_back_keys(app, ctx); about::show(app, ctx); settle(app); confirm::show_waiting(app); @@ -739,7 +740,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); let rejected = ctx.data_mut(|d| d.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); - if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) { ok = true; } if discard { diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 6c40bc355..3ae028bb0 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -312,9 +312,9 @@ fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { /// Presses Escape. fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) { - let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - frame(ctx, app, vec![key], ""); - frame(ctx, app, vec![], ""); + let key = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(ctx, app, vec![key(true)], ""); + frame(ctx, app, vec![key(false)], ""); } /// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens @@ -390,6 +390,24 @@ fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() { assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options"); } +/// Holding Escape closes Tips and leaves Brush Options open until a new press. +#[test] +fn holding_escape_closes_tips_and_leaves_the_dialog_open() { + let (mut app, ctx) = (app(), ctx()); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + set(&mut app, json!({"tips": true})); + let key = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(&ctx, &mut app, vec![], ""); + for repeat in 0..4 { + frame(&ctx, &mut app, vec![key(true)], ""); + let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("repeat {repeat}: Brush Options stays open")); + assert!(!d.bool("tips"), "repeat {repeat}: Tips closed"); + } + frame(&ctx, &mut app, vec![key(false)], ""); + frame(&ctx, &mut app, vec![key(true)], ""); + assert!(app.ui.dialog.is_none(), "a new press cancels Brush Options"); +} + /// Each Colorization Tips sample paints the brush with its own method on the blue stroke: /// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color /// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades. diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 4421888c7..8ab9cb9b8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -706,12 +706,14 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { out.textures_delta.clear(); }; let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let release = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }; let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); // Object › Transform › Rotate…: type 90 over the angle, press Enter. crate::menus::invoke(&mut app, "object.rotate", json!({})); frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text("90".into())]); frame(&mut app, vec![enter()]); + frame(&mut app, vec![release()]); assert!(app.ui.dialog.is_none()); let b = bounds(&app); assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); @@ -721,6 +723,7 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text("15".into())]); frame(&mut app, vec![enter()]); + frame(&mut app, vec![release()]); assert!(app.ui.dialog.is_none()); let after = bounds(&app); assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); @@ -732,6 +735,7 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text("50".into())]); frame(&mut app, vec![enter()]); + frame(&mut app, vec![release()]); assert!(app.ui.dialog.is_none()); let after = own(&app); assert!((after.width() - before.width() / 2.0).abs() < 1e-6 && (after.height() - before.height()).abs() < 1e-6, "{before:?} → {after:?}"); @@ -768,6 +772,7 @@ fn shift_enter_in_a_dialog_field_applies_it_and_confirms_the_dialog() { }; let shift_enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + let release = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::SHIFT }; let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); // Move…: type 15 over its first field and press Shift+Enter. let before = bounds(&app); @@ -776,6 +781,7 @@ fn shift_enter_in_a_dialog_field_applies_it_and_confirms_the_dialog() { frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text("15".into())]); frame(&mut app, vec![shift_enter()]); + frame(&mut app, vec![release()]); assert!(app.ui.dialog.is_none(), "Shift+Enter confirms the Move dialog"); let after = bounds(&app); assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs index 9f348410e..0249bb199 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -137,6 +137,10 @@ fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gest assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap()); assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap()); h.frame(vec![escape()], Modifiers::NONE); + h.frame( + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); h.release(p, Modifiers::NONE); assert!(h.app.ui.dialog.is_some()); assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 7cad17d0c..fda2e1f7f 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -77,6 +77,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if !app.ui.palette_open { return; } + crate::shortcuts::hold_back_keys(app, ctx); let t = Tokens::get(ctx); let q = app.ui.palette_query.to_lowercase(); let entries = entries(ctx, crate::i18n::current()); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1232aba36..f6ceaec9d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -185,6 +185,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); + note_key_surfaces(app, ctx); if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { crate::ime::controls_without_editor(app, ctx); return; @@ -210,7 +211,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d)); - if !popup && ctx.input(|i| i.key_pressed(Key::Escape)) { + if !popup && pressed_anew(ctx, Key::Escape) { crate::dialogs::cancel(app); app.ui.palette_open = false; } @@ -239,7 +240,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // below: the loop then skips Enter, the first key. let enter_types = app.session.tool_wants_text(); for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)].into_iter().skip(usize::from(enter_types)) { - if !typing && !composing && ctx.input(|i| i.key_pressed(k)) { + if !typing && !composing && !take_claimed_repeat(app, ctx, k, tk, view) && pressed_anew(ctx, k) { // A chord a command is bound to (Cmd+Enter) runs only that command, in the shortcuts // below, also when the tool claims the key. While the Type tool edits text only Cmd // chords run one, and while another tool is busy none does. @@ -376,6 +377,70 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { single_key_shortcuts(app, ctx); } +/// Did `key` go down in this frame? The key repeats of a held key do not count. +pub(crate) fn pressed_anew(ctx: &egui::Context, key: Key) -> bool { + ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, pressed: true, repeat: false, .. } if *k == key))) +} + +/// Takes this frame's repeats of `key` when the tool claims it: a press acts once, and they stay out +/// of the shortcuts. A chord a command is bound to keeps its repeats. +fn take_claimed_repeat(app: &mut VectorcraftApp, ctx: &egui::Context, key: Key, tool_key: ToolKey, view: ViewInfo) -> bool { + let text = app.session.tool_wants_text(); + let runs_a_command = (text || !app.session.tool_busy()) && ctx.input(|i| runs_a_command(i, key, text)); + if runs_a_command || pressed_anew(ctx, key) || !ctx.input(|i| i.key_pressed(key)) || !app.session.tool_claims_key(tool_key, view) { + return false; + } + ctx.input_mut(|i| i.consume_key(Modifiers::NONE, key)); + ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, key)); + true +} + +/// What Enter and Escape act on: the open dialog, the command search, Edit Toolbar's drawer and +/// Presentation Mode. +type KeySurface = (Option, bool, bool, bool); + +fn key_surface(app: &VectorcraftApp) -> KeySurface { + (app.ui.dialog.as_ref().map(|d| d.kind.clone()), app.ui.palette_open, app.ui.tools_drawer, app.ui.screen_mode == 3) +} + +/// The mark of what `key` went down on; `None` once that has changed. +fn key_surface_id(ctx: &egui::Context, key: Key) -> egui::Id { + egui::Id::unique("key-surface").with(key).with(ctx.viewport_id()) +} + +/// Notes what a new press of Enter or Escape lands on, and forgets it once the key is up. +fn note_key_surfaces(app: &VectorcraftApp, ctx: &egui::Context) { + for key in [Key::Enter, Key::Escape] { + let id = key_surface_id(ctx, key); + if pressed_anew(ctx, key) { + ctx.data_mut(|m| m.insert_temp(id, Some(key_surface(app)))); + } else { + hold_back_key(app, ctx, key); + if !ctx.input(|i| i.key_down(key)) { + ctx.data_mut(|m| m.remove::>(id)); + } + } + } +} + +/// Removes this frame's presses of Enter and Escape when the key went down on something that has +/// since changed: the press that opened or closed a dialog, and its repeats until the key is let +/// go, reach neither the new dialog nor what is under the old one. +pub(crate) fn hold_back_keys(app: &VectorcraftApp, ctx: &egui::Context) { + hold_back_key(app, ctx, Key::Enter); + hold_back_key(app, ctx, Key::Escape); +} + +fn hold_back_key(app: &VectorcraftApp, ctx: &egui::Context, key: Key) { + let id = key_surface_id(ctx, key); + let Some(pressed_on) = ctx.data(|m| m.get_temp::>(id)) else { return }; + if pressed_on.as_ref() == Some(&key_surface(app)) { + return; + } + ctx.data_mut(|m| m.insert_temp(id, None::)); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, .. } if *k == key))); +} + /// The key of the mark that the Escape held down ended a ruler-origin drag or a floating Tools move. fn escape_ended_gesture_id() -> egui::Id { egui::Id::unique("escape-ended-gesture") diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 80aa03365..5fbffb533 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1064,7 +1064,8 @@ fn enter_opens_the_tools_dialog_and_leaves_it_open() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let ctx = egui::Context::default(); - let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let enter = || vec![key(true), key(false)]; // A whole app frame: the shortcuts first, then the UI, where a dialog takes its Enter. let frame = |app: &mut VectorcraftApp, events: Vec| { let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); @@ -1078,7 +1079,7 @@ fn enter_opens_the_tools_dialog_and_leaves_it_open() { // The undo steps and the journal entries (the dialog's OK runs its command, which the journal records). let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); app.select_tool("selection"); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); assert_eq!(kind(&app), None, "nothing is selected"); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); for (tool, dialog) in [ @@ -1092,22 +1093,299 @@ fn enter_opens_the_tools_dialog_and_leaves_it_open() { ] { app.select_tool(tool); let before = steps(&app); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: Enter opens it"); frame(&mut app, vec![]); assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open"); assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it"); // The next Enter is its OK. With its default values only Reflect changes the art, so only // Reflect records an undo step. - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1); assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms"); } app.select_tool("rectangle"); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); assert_eq!(kind(&app), None, "another tool has its own Enter"); } +/// A key held down across whole app frames: the context stays, so egui marks the press after the +/// first as a repeat until `release` lets go. +struct HeldKey { + ctx: egui::Context, + key: Key, + modifiers: Modifiers, +} + +impl HeldKey { + fn new(key: Key, modifiers: Modifiers) -> Self { + Self { ctx: egui::Context::default(), key, modifiers } + } + + fn frame(&self, app: &mut VectorcraftApp, mut events: Vec) { + events.insert(0, egui::Event::ModifiersChanged(self.modifiers)); + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + + fn key(&self, app: &mut VectorcraftApp, pressed: bool) { + let event = egui::Event::Key { key: self.key, physical_key: None, pressed, repeat: false, modifiers: self.modifiers }; + self.frame(app, vec![event]); + } + + /// The press, or while the key is down, one repeat. + fn press(&self, app: &mut VectorcraftApp) { + self.key(app, true); + } + + fn release(&self, app: &mut VectorcraftApp) { + self.key(app, false); + } +} + +fn app_with_a_selected_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); + app +} + +fn open_dialog(app: &VectorcraftApp) -> Option { + app.ui.dialog.as_ref().map(|d| d.kind.clone()) +} + +/// Holding Enter with a selection opens Move once: the repeats neither confirm it nor open it +/// again. A new press confirms it, and that press's repeats do not open it again. +fn hold_enter_on_a_selection(modifiers: Modifiers) { + let mut app = app_with_a_selected_rectangle(); + let held = HeldKey::new(Key::Enter, modifiers); + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + app.select_tool("selection"); + let before = steps(&app); + for repeat in 0..8 { + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "repeat {repeat}: Move stays open"); + assert_eq!(steps(&app), before, "repeat {repeat}: nothing moved"); + } + assert!(held.ctx.memory(|m| m.focused().is_some()), "its first field keeps the keyboard"); + held.release(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "letting go leaves it open"); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "a new press confirms"); + let confirmed = steps(&app); + assert_eq!(confirmed.1, before.1 + 1, "the new press ran Move once"); + for repeat in 0..8 { + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "repeat {repeat}: Move stays closed"); + assert_eq!(steps(&app), confirmed, "repeat {repeat}: nothing else ran"); + } +} + +#[test] +fn holding_enter_opens_the_move_dialog_once_and_a_new_press_confirms_it() { + hold_enter_on_a_selection(Modifiers::NONE); +} + +#[test] +fn holding_shift_enter_opens_the_move_dialog_once_and_a_new_press_confirms_it() { + hold_enter_on_a_selection(Modifiers::SHIFT); +} + +/// After a drag Move opens on the drag's offset, and holding Enter leaves the object where it is. +#[test] +fn holding_enter_after_a_drag_does_not_move_the_object_again() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app_with_a_selected_rectangle(); + let left = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap().x0; + app.select_tool("selection"); + let view = app.view_info(); + for (kind, x) in [(PointerKind::Down, 35.0), (PointerKind::Drag, 55.0), (PointerKind::Drag, 75.0), (PointerKind::Up, 75.0)] { + app.session.pointer(&PointerEvent::new(kind, x, 25.0), view).unwrap(); + } + let dragged = left(&app); + assert!(dragged > 10.0); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + held.frame(&mut app, vec![]); + for repeat in 0..8 { + held.press(&mut app); + assert_eq!(left(&app), dragged, "repeat {repeat}: the object stays"); + } +} + +/// A chord no command is bound to opens Move through the tool's Enter too. That press is not also +/// the dialog's OK. +fn press_a_chord_on_a_selection(modifiers: Modifiers) { + let mut app = app_with_a_selected_rectangle(); + let journal = app.session.journal.len(); + let held = HeldKey::new(Key::Enter, modifiers); + app.select_tool("selection"); + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "Move stays open"); + assert_eq!(app.session.journal.len(), journal, "nothing ran"); +} + +#[test] +fn the_press_of_ctrl_enter_that_opens_a_dialog_does_not_confirm_it() { + press_a_chord_on_a_selection(Modifiers::CTRL); +} + +#[test] +fn the_press_of_cmd_enter_that_opens_a_dialog_does_not_confirm_it() { + press_a_chord_on_a_selection(Modifiers::COMMAND); +} + +/// A command bound to Enter opens its dialog once: the repeats of the press do not confirm it, and +/// the repeats of the press that confirms it do not run the command again. +#[test] +fn holding_enter_bound_to_a_dialog_command_opens_it_once() { + let mut app = app_with_a_selected_rectangle(); + app.run("shortcuts.set", json!({"id": "object.move", "shortcut": "Enter", "force": true})).unwrap(); + let journal = app.session.journal.len(); + app.select_tool("rectangle"); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + held.frame(&mut app, vec![]); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "repeat {repeat}: Move stays open"); + } + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "a new press confirms"); + let confirmed = app.session.journal.len(); + assert_eq!(confirmed, journal + 1); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "repeat {repeat}: the command does not run again"); + } +} + +/// The repeats of the Enter that confirms a dialog type no line break into the text being edited. +#[test] +fn holding_the_enter_that_confirms_a_dialog_types_no_line_break() { + let mut app = app_with_a_selected_rectangle(); + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + crate::menus::invoke(&mut app, "object.move", json!({})); + assert_eq!(open_dialog(&app).as_deref(), Some("move")); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + held.frame(&mut app, vec![]); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "the press confirms"); + assert!(app.session.tool_wants_text()); + let before = text_and_layers(&mut app, text).0; + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(text_and_layers(&mut app, text).0, before, "repeat {repeat}: no line break"); + } + held.release(&mut app); + held.press(&mut app); + assert_ne!(text_and_layers(&mut app, text).0, before, "a new press types one"); +} + +/// A release that never reaches the main window costs the next press: egui takes that press for a +/// repeat, and its release clears the key, so the press after it acts again. +#[test] +fn a_lost_release_costs_one_press_of_enter() { + let mut app = app_with_a_selected_rectangle(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + app.select_tool("rectangle"); + held.press(&mut app); + app.select_tool("selection"); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "the first press after the lost release is taken for a repeat"); + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "the next press opens Move"); +} + +/// A dialog that appears while Enter is down, as one the command search's Enter opens does, waits +/// for a new press. That covers the dialogs that draw their own window too. +#[test] +fn a_dialog_that_opens_while_enter_is_down_waits_for_a_new_press() { + fn open_move(app: &mut VectorcraftApp) { + let _ = crate::toolbar::open_options(app, "selection"); + } + fn open_simplify(app: &mut VectorcraftApp) { + crate::menus::invoke(app, "object.path.simplify", json!({})); + } + let new_document = crate::dialogs::open_new_document; + for (name, open) in [("Move", open_move as fn(&mut VectorcraftApp)), ("New Document", new_document), ("Simplify", open_simplify)] { + let mut app = app_with_a_selected_rectangle(); + let documents = app.session.documents().len(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + app.select_tool("rectangle"); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "{name}: the Rectangle tool opens nothing on Enter"); + open(&mut app); + let kind = open_dialog(&app); + assert!(kind.is_some(), "{name}: it opens"); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(open_dialog(&app), kind, "{name}, repeat {repeat}: it stays open"); + assert_eq!(app.session.documents().len(), documents, "{name}, repeat {repeat}: nothing was created"); + } + } +} + +/// The command search opens while Enter is down: the repeats do not run its highlighted result. +#[test] +fn the_command_search_that_opens_while_enter_is_down_waits_for_a_new_press() { + let mut app = app_with_a_selected_rectangle(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + app.select_tool("rectangle"); + held.press(&mut app); + app.ui.palette_open = true; + for repeat in 0..4 { + held.press(&mut app); + assert!(app.ui.palette_open, "repeat {repeat}: it stays open"); + } + held.release(&mut app); + held.press(&mut app); + assert!(!app.ui.palette_open, "a new press runs the highlighted result"); +} + +/// A field that rejects its text on Enter keeps the dialog open, while the key is held too. +#[test] +fn holding_enter_on_a_rejected_value_leaves_the_dialog_open() { + let mut app = app_with_a_selected_rectangle(); + let journal = app.session.journal.len(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + let _ = crate::toolbar::open_options(&mut app, "selection"); + held.frame(&mut app, vec![]); + held.frame(&mut app, vec![]); + held.frame(&mut app, vec![egui::Event::Text("abc".into())]); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "repeat {repeat}: Move stays open"); + assert_eq!(app.session.journal.len(), journal, "repeat {repeat}: nothing ran"); + } +} + +/// Holding Enter in the Type tool goes on typing line breaks. +#[test] +fn holding_enter_in_the_type_tool_repeats_line_breaks() { + for modifiers in [Modifiers::NONE, Modifiers::SHIFT] { + let mut app = app_with_a_selected_rectangle(); + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + let held = HeldKey::new(Key::Enter, modifiers); + for _ in 0..4 { + held.press(&mut app); + } + let typed = text_and_layers(&mut app, text).0; + assert_eq!(typed.chars().filter(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{3}')).count(), 4, "{modifiers:?}: {typed:?}"); + assert_eq!(open_dialog(&app), None, "{modifiers:?}"); + } +} + /// A drag with the Selection tool, then Enter: the Move dialog opens on the drag's offset, and /// Enter is its OK. With Copy ticked once, each Enter, Enter after that makes another copy at /// the same spacing. @@ -1118,7 +1396,8 @@ fn enter_after_a_drag_repeats_its_offset() { app.session.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); let ctx = egui::Context::default(); - let enter = || egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let enter = || vec![key(true), key(false)]; let frame = |app: &mut VectorcraftApp, events: Vec| { let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| { @@ -1143,26 +1422,26 @@ fn enter_after_a_drag_repeats_its_offset() { assert!(step > 0.0); // Keys reach the canvas from the second frame on. frame(&mut app, vec![]); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); let d = app.ui.dialog.as_mut().expect("Enter opens Move"); assert_eq!((d.kind.as_str(), d.f64("dx", f64::NAN).round(), d.f64("dy", f64::NAN)), ("move", step, 0.0)); d.fields.insert("copy".into(), json!(true)); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); for _ in 0..2 { - frame(&mut app, vec![enter()]); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); + frame(&mut app, enter()); } assert!(app.ui.dialog.is_none()); assert_eq!(lefts(&app), (0..4).map(|i| moved + step * f64::from(i)).collect::>()); // With Preview on too, each Enter, Enter still makes one copy, and one undo step. - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); app.ui.dialog.as_mut().expect("Move").fields.insert("preview".into(), json!(true)); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); let steps = app.session.active().unwrap().history.undo.len(); for _ in 0..2 { - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("preview") && d.bool("copy")) && app.session.in_interaction()); - frame(&mut app, vec![enter()]); + frame(&mut app, enter()); assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); } assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 2); diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index 432fdaec0..000667dba 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -710,6 +710,57 @@ fn tool_key(app: &mut VectorcraftApp, key: vectorcraft_tools::ToolKey) { /// #734: Crop Image shows a crop box on the image instead of cropping to the artboard at once (an /// image inside the artboard had nothing to cut); Apply crops to the box in one undo step, Cancel /// leaves the image whole, and both go back to the Selection tool. +/// Holding Escape with the place cursor loaded discards one file for the press. The repeats discard +/// no more and run no command bound to Escape. +#[test] +fn holding_escape_with_the_place_cursor_loaded_discards_one_file() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("shortcuts.set", json!({"id": "select.none", "shortcut": "Escape", "force": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let paths: Vec = ["held-a.png", "held-b.png", "held-c.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + app.run("file.place", json!({"paths": paths})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options()["count"], 3); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert!(!selected.is_empty()); + let key = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![], &[], false); + for repeat in 0..4 { + frame(&mut app, &ctx, vec![key(true)], &[], false); + assert_eq!(app.session.tool_options()["count"], 2, "repeat {repeat}: one file is discarded"); + assert_eq!(app.session.active().unwrap().selection.objects, selected, "repeat {repeat}: no command ran"); + } + frame(&mut app, &ctx, vec![key(false)], &[], false); + frame(&mut app, &ctx, vec![key(true)], &[], false); + assert_eq!(app.session.tool_options()["count"], 1, "a new press discards the next file"); +} + +/// A command bound to Shift+Escape keeps repeating with the place cursor loaded, and no file is +/// discarded. +#[test] +fn holding_a_chord_bound_to_a_command_repeats_it_with_the_place_cursor_loaded() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("shortcuts.set", json!({"id": "view.zoomIn", "shortcut": "Shift+Escape", "force": true})).unwrap(); + let paths: Vec = ["chord-a.png", "chord-b.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + app.run("file.place", json!({"paths": paths})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let modifiers = egui::Modifiers::SHIFT; + let press = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&mut app, &ctx, vec![], &[], false); + let mut zoom = app.view().unwrap().zoom; + for repeat in 0..3 { + frame(&mut app, &ctx, vec![press.clone()], &[], true); + assert!(app.view().unwrap().zoom > zoom, "repeat {repeat}: the command runs"); + assert_eq!(app.session.tool_options()["count"], 2, "repeat {repeat}: no file is discarded"); + zoom = app.view().unwrap().zoom; + } +} + #[test] fn crop_image_shows_a_box_that_apply_crops_to() { use vectorcraft_tools::ToolKey; diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 3dffb4aa8..56bcca519 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -488,6 +488,65 @@ fn held_escape_that_cancels_a_move_skips_a_command_bound_to_escape() { } } +/// Holding Escape cancels the open dialog and does nothing else: Edit Toolbar's drawer stays open +/// and isolation mode stays on, until new presses close the drawer and then leave the mode. +#[test] +fn held_escape_that_cancels_a_dialog_stays_in_isolation_mode() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 0); + let group = app.session.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("object.isolate", &json!({"id": group})).unwrap(); + let isolated = |app: &VectorcraftApp| app.session.active().unwrap().isolation.is_some(); + assert!(isolated(&app)); + app.session.execute("select.all", &json!({})).unwrap(); + app.select_tool("selection"); + let _ = crate::toolbar::open_options(&mut app, "selection"); + assert!(app.ui.dialog.is_some()); + app.ui.tools_drawer = true; + frame(&mut app, &ctx, escape_event(true, false)); + assert!(app.ui.dialog.is_none(), "Escape cancels Move"); + for repeat in 0..4 { + frame(&mut app, &ctx, escape_event(true, true)); + assert!(app.ui.tools_drawer, "repeat {repeat}: the drawer stays open"); + assert!(isolated(&app), "repeat {repeat}: isolation mode stays on"); + } + frame(&mut app, &ctx, escape_event(false, false)); + frame(&mut app, &ctx, escape()); + assert!(!app.ui.tools_drawer && isolated(&app), "a new press closes the drawer"); + frame(&mut app, &ctx, escape()); + assert!(!isolated(&app), "the next new press leaves isolation mode"); +} + +/// The repeats of the Escape that cancels a dialog or leaves Presentation Mode run no command bound +/// to Escape. A new press does. +#[test] +fn held_escape_that_cancels_a_dialog_or_leaves_presentation_skips_a_command_bound_to_escape() { + for presentation in [false, true] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 0); + app.run("shortcuts.set", json!({"id": "select.none", "shortcut": "Escape", "force": true})).unwrap(); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert!(!selected.is_empty()); + if presentation { + app.run("view.presentation", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + } else { + app.select_tool("selection"); + let _ = crate::toolbar::open_options(&mut app, "selection"); + assert!(app.ui.dialog.is_some()); + } + frame(&mut app, &ctx, escape_event(true, false)); + assert!(app.ui.dialog.is_none() && app.ui.screen_mode != 3, "the press ends it (presentation: {presentation})"); + for repeat in 0..4 { + frame(&mut app, &ctx, escape_event(true, true)); + assert_eq!(app.session.active().unwrap().selection.objects, selected, "repeat {repeat} (presentation: {presentation})"); + } + frame(&mut app, &ctx, escape_event(false, false)); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "a new press runs the command (presentation: {presentation})"); + } +} + #[test] fn a_popup_takes_escape_before_full_screen_exits() { let ctx = egui::Context::default();