From 96e99952d27f27a0adf4e5fd191bb41e11b373ea Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 17 Jul 2026 20:17:47 +0900 Subject: [PATCH] fix(app): restore first responder after native tab close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a native macOS tab left the surviving tab unable to receive keyboard input: AppKit's tab teardown makes the survivor key but moves first responder off winit's content NSView, so keyDown: never reaches WinitView and WindowEvent::KeyboardInput goes silent. The previous deferred-RestoreFocus fix only called focus_window() (makeKeyAndOrderFront:), which does not reassign first responder when the target window is already key — a no-op for this failure mode. Add macos_window::focus_content_view, which resolves the AppKit NSView from the raw window handle and sends makeFirstResponder: to its window. Call it (plus a set_ime_allowed off/on toggle to discard stale marked text) from both the RestoreFocus handler and the Focused(true) path, so recovery does not depend on where the deferred event lands relative to AppKit's teardown. Non-macOS paths unchanged. --- crates/noa-app/src/app/event_loop.rs | 22 ++++++++++++++++ crates/noa-app/src/macos_window.rs | 38 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/crates/noa-app/src/app/event_loop.rs b/crates/noa-app/src/app/event_loop.rs index 0adf070..8626960 100644 --- a/crates/noa-app/src/app/event_loop.rs +++ b/crates/noa-app/src/app/event_loop.rs @@ -283,6 +283,18 @@ impl ApplicationHandler for App { .map(|state| state.window.clone()) { window.focus_window(); + // AppKit's native-tab teardown drops first responder off + // winit's content view, so `focus_window` (already-key + // window) leaves `KeyboardInput` dead. Re-target first + // responder at the content view, then discard any stale + // marked text by toggling IME off/on (same clear the + // `Focused(false)` path uses). + #[cfg(target_os = "macos")] + { + crate::macos_window::focus_content_view(&window); + window.set_ime_allowed(false); + window.set_ime_allowed(true); + } } } UserEvent::PtyExit(window_id, pane_id) => { @@ -375,6 +387,16 @@ 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(); } } diff --git a/crates/noa-app/src/macos_window.rs b/crates/noa-app/src/macos_window.rs index 6219091..391160e 100644 --- a/crates/noa-app/src/macos_window.rs +++ b/crates/noa-app/src/macos_window.rs @@ -22,6 +22,15 @@ pub(crate) fn show_quick_terminal_window(window: &Window) { show_quick_terminal_window_impl(window); } +/// Restore first responder to winit's content `NSView`. AppKit's native-tab +/// teardown moves first responder off the text-input view, so `KeyboardInput` +/// stops arriving even though the window is already key — `focus_window` +/// (`makeKeyAndOrderFront:`) can't fix that on an already-key window. This +/// re-targets first responder at the content view directly. +pub(crate) fn focus_content_view(window: &Window) { + focus_content_view_impl(window); +} + /// Commit a just-shown window to the screen immediately: draw any dirty /// views (`displayIfNeeded`) and flush the pending window-server transaction /// (`CATransaction flush`). Startup W1: the first window is shown from inside @@ -454,6 +463,35 @@ fn show_quick_terminal_window_impl(window: &Window) { } } +#[cfg(target_os = "macos")] +fn focus_content_view_impl(window: &Window) { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { + return; + }; + let ns_view = appkit.ns_view.as_ptr().cast::(); + + // SAFETY: `ns_view` is winit's live AppKit `NSView` for this window and we + // are on the main event-loop thread. `makeFirstResponder:` is an AppKit + // window method; both pointers are nil-checked before use. + unsafe { + let ns_window: *mut AnyObject = msg_send![ns_view, window]; + if ns_window.is_null() { + return; + } + let _: bool = msg_send![ns_window, makeFirstResponder: ns_view]; + } +} + +#[cfg(not(target_os = "macos"))] +fn focus_content_view_impl(_window: &Window) {} + #[cfg(not(target_os = "macos"))] fn show_quick_terminal_window_impl(window: &Window) { window.focus_window();