Merge pull request #6004 from dotCooCoo/fix/dialog-enter-repeat

Dialogs: a held Enter or Escape stops at the dialog it opened or closed
This commit is contained in:
BFlat
2026-11-07 09:56:52 +08:00
committed by GitHub
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();