From 94d30eaea3d4e8c428161821415e7225ad693556 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Oct 2024 14:04:29 -0700 Subject: [PATCH] macos: retry focusing the quick terminal to handle focus on other screen Fixes #2409 This is one of the weirder macOS quirks (bugs? who knows!) I've seen recently. The bug as described in #2409: when you have at least two monitors ("screens" in AppKit parlance), Ghostty on one, a focused app on the other, and you toggle the quick terminal, the quick terminal does not have focus. We already knew and accounted for the fact that `window.makeKeyAndOrderFront(nil)` does not work until the window is visible and on the target screen. To do this, we only called this once the animation was complete. For the same NSScreen, this works, but for another screen, it does not. Using one DispatchQueue.async tick also does not work. Based on testing, it takes anywhere from 2 to 5 ticks to get the window focus API to work properly. Okay. The solution I came up with here is to retry the focus operation every 25ms up to 250ms. This has worked consistently for me within the first 5 ticks but it is obviously a hack so I'm not sure if this is all right. This fixes the issue but if there's a better way to do this, I'm all ears! --- .../QuickTerminalController.swift | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 89bb4ddc5..13b69d000 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -195,28 +195,63 @@ class QuickTerminalController: BaseTerminalController { // If we canceled our animation in we do nothing guard self.visible else { return } - // If our focused view is somehow not connected to this window then the - // function calls below do nothing. I don't think this is possible but - // we should guard against it because it is a Cocoa assertion. - guard let focusedView = self.focusedSurface, - focusedView.window == window else { return } - - // The window must become top-level - window.makeKeyAndOrderFront(nil) - - // The view must gain our keyboard focus - window.makeFirstResponder(focusedView) + // Once our animation is done, we must grab focus since we can't grab + // focus of a non-visible window. + self.makeWindowKey(window) // If our application is not active, then we grab focus. Its important // we do this AFTER our window is animated in and focused because // otherwise macOS will bring forward another window. if !NSApp.isActive { NSApp.activate(ignoringOtherApps: true) + + // This works around a really funky bug where if the terminal is + // shown on a screen that has no other Ghostty windows, it takes + // a few (variable) event loop ticks until we can actually focus it. + // https://github.com/ghostty-org/ghostty/issues/2409 + // + // We wait one event loop tick to try it because under the happy + // path (we have windows on this screen) it takes one event loop + // tick for window.isKeyWindow to return true. + DispatchQueue.main.async { + guard !window.isKeyWindow else { return } + self.makeWindowKey(window, retries: 10) + } } } }) } + /// Attempt to make a window key, supporting retries if necessary. The retries will be attempted + /// on a separate event loop tick. + /// + /// The window must contain the focused surface for this terminal controller. + private func makeWindowKey(_ window: NSWindow, retries: UInt8 = 0) { + // We must be visible + guard visible else { return } + + // If our focused view is somehow not connected to this window then the + // function calls below do nothing. I don't think this is possible but + // we should guard against it because it is a Cocoa assertion. + guard let focusedSurface, focusedSurface.window == window else { return } + + // The window must become top-level + window.makeKeyAndOrderFront(nil) + + // The view must gain our keyboard focus + window.makeFirstResponder(focusedSurface) + + // If our window is already key then we're done! + guard !window.isKeyWindow else { return } + + // If we don't have retries then we're done + guard retries > 0 else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { + self.makeWindowKey(window, retries: retries - 1) + } + } + private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { // We always animate out to whatever screen the window is actually on. guard let screen = window.screen ?? NSScreen.main else { return }