Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ impl ApplicationHandler<UserEvent> 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
Expand Down Expand Up @@ -387,20 +392,19 @@ impl ApplicationHandler<UserEvent> 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve held modifiers across internal focus switches

The code just below notes that macOS can deliver the outgoing tab/window's Focused(false) after the incoming one is already focused; clearing the global self.modifiers in that outgoing event drops still-held Cmd/Shift. If a user holds ⌘⇧ and presses ] repeatedly to cycle tabs, the first switch can succeed but the next KeyboardInput is resolved with empty modifiers (the state is only restored by a later modifier change), so the shortcut is ignored or sent to the PTY. Gate this reset to actual app deactivation or another stale-modifier recovery path rather than every focus loss.

Useful? React with 👍 / 👎.

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
Expand Down
21 changes: 21 additions & 0 deletions crates/noa-app/src/commands/keybind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down