Dialogs: a held Enter or Escape stops at the dialog it opened or closed

Enter and Escape counted their key repeats as presses. Holding Enter with
a selection and a selection tool opened the Move dialog, confirmed it,
and opened it again at the key's repeat rate. After a drag, which gives
Move an offset, each round moved the object again.

The app now records what Enter or Escape went down on: the open dialog,
the command search, Edit Toolbar's drawer or Presentation Mode. When that
changes, the key's presses are removed from the input until it is let go.
The tool keys, the dialog's OK and the dialog's Escape also ignore
repeats, so holding Escape with the place cursor loaded discards one file.
A new press still acts, and Enter held in the Type tool still repeats line
breaks.

This also fixes these related faults:
- Ctrl+Enter or Cmd+Enter opened Move and confirmed it in the same frame.
- Holding the Enter that confirmed a dialog typed line breaks into the
  text being edited.
- A command bound to Enter reopened its dialog after the dialog's OK.
- Holding Enter on a value a field rejected confirmed the dialog.
- A command search that opened while Enter was down ran its first result.
- Holding Escape over a dialog's Tips closed the dialog as well.
- Holding Escape over a dialog went on to close Edit Toolbar's drawer,
  leave isolation mode or run a command bound to Escape.
This commit is contained in:
dotCooCoo
2026-11-07 08:38:15 +07:00
committed by bflatastic
parent 2e8e8a1909
commit 20f35e4655
9 changed files with 504 additions and 20 deletions
+2 -1
View File
@@ -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::<bool>(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 {
@@ -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.
+6
View File
@@ -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:?}");
@@ -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);
+1
View File
@@ -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());
+67 -2
View File
@@ -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<String>, 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::<Option<KeySurface>>(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::<Option<KeySurface>>(id)) else { return };
if pressed_on.as_ref() == Some(&key_surface(app)) {
return;
}
ctx.data_mut(|m| m.insert_temp(id, None::<KeySurface>));
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")
+293 -14
View File
@@ -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<egui::Event>| {
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<egui::Event>) {
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<String> {
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<egui::Event>| {
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::<Vec<_>>());
// 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);
+51
View File
@@ -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<String> = ["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<String> = ["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;
+59
View File
@@ -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();