From 57361f46624a3ea21c67cf9b62927c2f4e0f4e5d Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 17 Jul 2026 20:46:24 +0900 Subject: [PATCH] fix(app): reset stale modifiers on focus loss, drop duplicate focus restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the native-tab-close focus fix (#26): Search opening uninvited: closing a tab with cmd+w destroys the view before the Cmd-release flagsChanged reaches winit, so the app's stored modifiers stay stuck holding SUPER and the next plain keypress on the surviving tab resolves as a chord (plain f -> cmd+f -> Search/Find). Reset stored modifiers on Focused(false) and in the RestoreFocus handler; winit re-sends ModifiersChanged on the next real modifier press, so nothing is lost. Regression test locks the resolution semantics the reset relies on. Intermittent crash on the same gesture: the belt-and-suspenders block added to Focused(true) ran makeFirstResponder: plus an IME off/on toggle synchronously inside the winit handler on every focus gain, including mid-teardown ones — a re-entrancy/teardown race. Remove it; focus restore after tab close lives solely on the deferred RestoreFocus path, which keeps the first-responder + IME re-arm. --- crates/noa-app/src/app/event_loop.rs | 24 ++++++++++++++---------- crates/noa-app/src/commands/keybind.rs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/noa-app/src/app/event_loop.rs b/crates/noa-app/src/app/event_loop.rs index 8626960..c2d7cc8 100644 --- a/crates/noa-app/src/app/event_loop.rs +++ b/crates/noa-app/src/app/event_loop.rs @@ -275,6 +275,11 @@ impl ApplicationHandler for App { self.handle_remote_request_completed(event_loop, request_id) } UserEvent::RestoreFocus { window_id } => { + // Defensive twin of the `Focused(false)` reset: the deferred + // focus restore runs the tab-close teardown path that can eat + // the Cmd-up event, so clear any stale held modifiers here too + // rather than trusting them to survive the transition. + self.modifiers = winit::keyboard::ModifiersState::empty(); let target_exists = self.windows.contains_key(&window_id); if should_apply_deferred_focus_restore(window_id, self.focused, target_exists) && let Some(window) = self @@ -387,20 +392,19 @@ impl ApplicationHandler for App { self.secure_input .on_focus_change(true, &mut crate::secure_input::CarbonSecureInput); if let Some(state) = self.windows.get(&window_id) { - // Belt-and-suspenders for the native-tab-close focus loss: - // whether or not the deferred `RestoreFocus` lands, a real - // focus gain re-anchors first responder on the content view - // and re-arms IME so key input can't stay dead. - #[cfg(target_os = "macos")] - { - crate::macos_window::focus_content_view(&state.window); - state.window.set_ime_allowed(false); - state.window.set_ime_allowed(true); - } state.window.request_redraw(); } } WindowEvent::Focused(false) => { + // Focus transitions can eat the modifier-up event — most + // notably native-tab teardown on ⌘W, where the Cmd-release + // `flagsChanged` lands on the torn-down view and never reaches + // winit — leaving `self.modifiers` stuck holding SUPER. Stale + // held modifiers then mis-resolve the next plain keypress as a + // chord (a plain `f` → `cmd+f` → Search/Find). Clear on focus + // loss; winit re-sends `ModifiersChanged` on the next real + // modifier press, so nothing is lost. + self.modifiers = winit::keyboard::ModifiersState::empty(); self.end_copy_mode_for_window(window_id); // Only clear if this window is the one we recorded as focused — // when macOS switches between our own windows the incoming diff --git a/crates/noa-app/src/commands/keybind.rs b/crates/noa-app/src/commands/keybind.rs index 78158c3..7816a6e 100644 --- a/crates/noa-app/src/commands/keybind.rs +++ b/crates/noa-app/src/commands/keybind.rs @@ -737,6 +737,27 @@ mod tests { ); } + // Regression guard for the "勝手にSearchモード" bug: a focus transition + // (native-tab close on ⌘W) can eat the Cmd-up event, leaving the app's + // stored modifiers stuck holding SUPER. The fix resets them to empty on + // focus loss. This locks the resolution semantics that reset relies on — + // a plain `f` under the reset (empty) modifiers must NOT resolve to + // Search/Find, while a live SUPER still does. + #[test] + fn plain_f_with_reset_modifiers_does_not_resolve_to_find() { + let engine = KeybindEngine::default(); + assert_eq!( + engine.resolve(&Key::Character("f".into()), ModifiersState::empty()), + None, + "plain f with reset (empty) modifiers must not trigger Search/Find" + ); + assert_eq!( + engine.resolve(&Key::Character("f".into()), ModifiersState::SUPER), + Some(AppCommand::Search(SearchAction::Find)), + "cmd+f with live SUPER must still trigger Search/Find" + ); + } + // The empty sentinel (`sidebar-hotkey = none`) keeps the default // `cmd+shift+s` binding; an unparseable chord is diagnosed and ignored. #[test]