diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 33b0b2d57..63158b732 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -367,7 +367,8 @@ pub(crate) struct Shown { /// The pass number and time of the last frame that drew it. pass: Option, last: Option, - /// A pointer button went down before OK took input, and hasn't been let go of. + /// A pointer button, Enter or Space was down on a frame where OK was disabled, and they haven't + /// all been let go of since. pressed_early: bool, /// OK takes input: shown for [`APPROVAL_DELAY`], with no press from before that held. ready: bool, @@ -375,14 +376,19 @@ pub(crate) struct Shown { impl Shown { /// Notes a frame that draws the question in `ui` → whether OK is enabled on it. The showing - /// starts over when the frame before didn't draw it, when time went back, and on any frame - /// that is a measuring pass, is invisible or comes while the window doesn't have the focus. + /// starts over when the frame before didn't draw it, when time went back, when the window lost + /// the focus since the frame before, and on any frame that is a measuring pass, is invisible or + /// comes while the window doesn't have the focus. fn drawn(&mut self, ui: &egui::Ui) -> bool { let pass = ui.ctx().cumulative_pass_nr(); - let (now, focused, pressed) = ui.input(|i| (i.time, i.focused, i.pointer.any_pressed())); + let (now, focused, left, pressed) = ui.input(|i| { + let left = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); + let pressed = i.pointer.any_pressed() || [egui::Key::Enter, egui::Key::Space].into_iter().any(|k| i.key_pressed(k)); + (i.time, i.focused, left, pressed || Self::held(i)) + }); let seen = focused && ui.is_visible() && !ui.is_sizing_pass() && now.is_finite(); let follows = self.pass.is_some_and(|p| p.checked_add(1) == Some(pass)) && self.last.is_some_and(|t| t <= now); - if !seen || !follows { + if !seen || !follows || left { self.since = None; } if seen && self.since.is_none() { @@ -390,8 +396,8 @@ impl Shown { } (self.pass, self.last) = (Some(pass), Some(now)); let enabled = self.since.is_some_and(|s| now - s >= APPROVAL_DELAY); - if pressed { - self.pressed_early = !enabled; + if pressed && !enabled { + self.pressed_early = true; } self.ready = enabled && !self.pressed_early; if let Some(s) = self.since.filter(|_| !enabled) { @@ -401,13 +407,19 @@ impl Shown { } /// After the frame's OK: a press from before OK took input stops counting once every pointer - /// button is up. + /// button, Enter and Space are up. fn released(&mut self, ui: &egui::Ui) { - if !ui.input(|i| i.pointer.any_down()) { + if !ui.input(Self::held) { self.pressed_early = false; } } + /// Whether a pointer button, Enter or Space is down: what can click OK when it is let go of or + /// repeats. + fn held(i: &egui::InputState) -> bool { + i.pointer.any_down() || i.key_down(egui::Key::Enter) || i.key_down(egui::Key::Space) + } + /// Whether OK takes input: the question was shown for [`APPROVAL_DELAY`] on the last frame /// that drew it. pub(crate) fn ready(&self) -> bool { diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 135d2d343..105c22326 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -395,6 +395,133 @@ fn a_press_on_the_install_approval_from_before_ok_took_input_approves_nothing() h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); } +/// A press of another button after OK took input doesn't let a primary press from before it +/// approve when that press is let go of. +#[test] +fn a_later_press_on_the_install_approval_keeps_an_early_press_from_approving() { + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.held.two"; + let mut h = window(app()); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Two", r#"["doc.read"]"#))}); + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + let pos = h.get_by_label("OK").rect().center(); + let button = |button, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + // The primary button goes down away from any widget and moves over OK. + let away = egui::pos2(2.0, 2.0); + h.input_mut().events.extend([ + egui::Event::PointerMoved(away), + egui::Event::PointerButton { pos: away, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + at(&mut h, 10.1); + h.input_mut().events.push(egui::Event::PointerMoved(pos)); + at(&mut h, 10.2); + at(&mut h, 10.5); + assert!(ok_enabled(&h)); + h.input_mut().events.push(button(egui::PointerButton::Secondary, true)); + at(&mut h, 10.55); + h.input_mut().events.push(button(egui::PointerButton::Primary, false)); + at(&mut h, 10.6); + assert!(waiting(h.state(), id), "the primary press began before OK took input"); + h.input_mut().events.push(button(egui::PointerButton::Secondary, false)); + at(&mut h, 10.65); + click_ok(&mut h, 10.8); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// A press on the OK of one question that is still held when another question takes its place +/// doesn't approve the new one when it is let go of. +#[test] +fn a_press_held_from_the_question_before_approves_nothing() { + use egui_kittest::kittest::Queryable; + let (a, b) = ("test.ui.approval.carried.a", "test.ui.approval.carried.b"); + let module = |id: &str| json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Carried", r#"["doc.read"]"#))}); + let mut h = window(app()); + h.state_mut().run("ui.installPlugin", module(a)).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + let pos = h.get_by_label("OK").rect().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.input_mut().events.extend([egui::Event::PointerMoved(pos), button(true)]); + at(&mut h, 10.65); + // While the button is down, a request closes the question and asks about another module. + dialogs::cancel(h.state_mut()); + h.state_mut().run("ui.installPlugin", module(b)).unwrap(); + at(&mut h, 10.7); + at(&mut h, 11.0); + at(&mut h, 11.2); + assert!(ok_enabled(&h)); + h.input_mut().events.push(button(false)); + at(&mut h, 11.25); + assert!(waiting(h.state(), b) && vectorcraft_plugins::registry::get(a).is_none(), "the press began on the question before"); + click_ok(&mut h, 11.4); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(b).is_some()); + h.state_mut().run("plugin.remove", json!({"id": b})).unwrap(); +} + +/// Enter or Space held down while OK is disabled doesn't approve through its key repeat once OK +/// has the keyboard focus; a new press after it is let go of does. +#[test] +fn a_key_held_on_the_install_approval_from_before_ok_took_input_approves_nothing() { + use egui_kittest::kittest::Queryable; + for (n, key) in [egui::Key::Enter, egui::Key::Space].into_iter().enumerate() { + let id = format!("test.ui.approval.key.{n}"); + let mut h = window(app()); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(&id, "Key", r#"["doc.read"]"#))}); + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + let event = |pressed, repeat| egui::Event::Key { key, physical_key: Some(key), pressed, repeat, modifiers: Default::default() }; + h.input_mut().events.push(event(true, false)); + at(&mut h, 10.1); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + h.get_by_label("OK").focus(); + at(&mut h, 10.65); + h.input_mut().events.push(event(true, true)); + at(&mut h, 10.7); + assert!(waiting(h.state(), &id), "{key:?}: the key went down before OK took input"); + h.input_mut().events.push(event(false, false)); + at(&mut h, 10.75); + h.get_by_label("OK").focus(); + at(&mut h, 10.8); + h.key_press(key); + at(&mut h, 10.85); + assert!(h.state().ui.dialog.is_none(), "{key:?}: {}", h.state().ui.status); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); + } +} + +/// The wait starts over when the window loses the focus and gets it back between two frames. +#[test] +fn the_install_approval_waits_again_when_the_focus_leaves_and_comes_back_between_frames() { + let id = "test.ui.approval.blink"; + let mut h = window(app()); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Blink", r#"["doc.read"]"#))}); + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + h.input_mut().events.extend([egui::Event::WindowFocused(false), egui::Event::WindowFocused(true)]); + at(&mut h, 10.7); + assert!(!ok_enabled(&h), "the focus left since the frame before"); + click_ok(&mut h, 10.8); + assert!(waiting(h.state(), id)); + at(&mut h, 11.19); + assert!(!ok_enabled(&h)); + at(&mut h, 11.2); + assert!(ok_enabled(&h), "back in front, it waits again"); + click_ok(&mut h, 11.3); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + /// A module with other bytes than the one installed with its id asks before taking its place: in /// the ordinary confirm when it needs nothing that only a person can approve, and in the approval /// dialog, with its id and file hash, when it also asks for a heavy budget.