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();