mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user