diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cfb30cd4..535272ad 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -138,6 +138,61 @@ func browserZoomShortcutAction( return nil } +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil +} + +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } + + let key = chars.lowercased() + if key == "=" || key == "+" || key == "-" || key == "_" || key == "0" { + return true + } + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false + } +} + +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} + +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" + } +} +#endif + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? @@ -2044,21 +2099,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if let action = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode), - let manager = tabManager { + #if DEBUG + logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) + #endif + let zoomAction = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode) + #if DEBUG + logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) + #endif + if let action = zoomAction, let manager = tabManager { + let handled: Bool switch action { case .zoomIn: - return manager.zoomInFocusedBrowser() + handled = manager.zoomInFocusedBrowser() case .zoomOut: - return manager.zoomOutFocusedBrowser() + handled = manager.zoomOutFocusedBrowser() case .reset: - return manager.resetZoomFocusedBrowser() + handled = manager.resetZoomFocusedBrowser() } + #if DEBUG + logBrowserZoomShortcutTrace( + stage: "dispatch", + event: event, + flags: flags, + chars: chars, + action: action, + handled: handled + ) + #endif + return handled } + #if DEBUG + if zoomAction != nil, tabManager == nil { + logBrowserZoomShortcutTrace( + stage: "dispatch.noManager", + event: event, + flags: flags, + chars: chars, + action: zoomAction, + handled: false + ) + } + #endif return false } +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, + event: NSEvent, + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate(flags: flags, chars: chars, keyCode: event.keyCode) else { + return + } + + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) + } +#endif + @discardableResult private func focusBrowserAddressBar(panelId: UUID) -> Bool { guard let tabManager, @@ -3672,6 +3788,22 @@ private extension NSWindow { #endif return result } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } } if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { @@ -3686,11 +3818,28 @@ private extension NSWindow { // events directly to the main menu. This avoids the broken SwiftUI focus path. if self.firstResponder is GhosttyNSView, event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), - let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) #if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } #endif - return true + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } } let result = cmux_performKeyEquivalent(with: event) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d66de4b7..9099128f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -721,6 +721,57 @@ final class BrowserZoomShortcutActionTests: XCTestCase { } } +final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { + func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "-", + keyCode: 27 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "0", + keyCode: 29 + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotGhostty() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: false, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + } + + func testDoesNotRouteForNonZoomShortcuts() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } +} + final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintRequiresCommandOnlyModifier() { XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))