diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eed6075af797..2db737ddd498 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14323,14 +14323,6 @@ private extension NSWindow { } if let ghosttyView = firstResponderGhosttyView { - if ghosttyView.shouldRouteTextInputKeyEquivalentToKeyDown(event) { - ghosttyView.keyDown(with: event) -#if DEBUG - cmuxDebugLog(" → terminal text-input key equivalent routed to keyDown") -#endif - return true - } - // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can // process it. Cmd-based shortcuts should still work during composition since diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index 09144783bbf0..dc51ba0abfe9 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -1,5 +1,4 @@ import AppKit -import Carbon.HIToolbox extension GhosttyNSView { /// Clamps AppKit's marked-text selection into the active preedit buffer. @@ -34,141 +33,19 @@ extension GhosttyNSView { func shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (text: String, selection: NSRange), after: (text: String, selection: NSRange), - accumulatedText: [String], - event: NSEvent? = nil, - textInputHandledEvent: Bool = false, - inputSourceId: String? = nil + accumulatedText: [String] ) -> Bool { guard accumulatedText.isEmpty else { return false } let hadMarkedTextBefore = !before.text.isEmpty let hasMarkedTextAfter = !after.text.isEmpty - guard hadMarkedTextBefore || hasMarkedTextAfter else { - // Some IMEs, including Traditional Chinese Zhuyin, can handle a - // command key against their private preedit buffer before they call - // setMarkedText on the client. Keep handled no-output input-method - // events out of the terminal so keys such as Down can open - // candidates instead of moving the shell cursor. - guard textInputHandledEvent, isInputMethodSource(inputSourceId) else { return false } - return !shouldAllowDeferredNumpadIMEFallback(event) - } + guard hadMarkedTextBefore || hasMarkedTextAfter else { return false } if before.text != after.text { return true } - if before.selection != after.selection { - return true - } - - guard let event, isInputMethodSource(inputSourceId) else { - return false - } - return shouldKeepIMECompositionCommandInsideTextInput(event) - } - - func isInputMethodSource(_ sourceId: String?) -> Bool { - guard let sourceId else { return false } - return sourceId.localizedCaseInsensitiveContains("inputmethod") - } - - func shouldAllowDeferredNumpadIMEFallback(_ event: NSEvent?) -> Bool { - guard let event, - let text = event.characters, - !text.isEmpty, - text.allSatisfy(\.isNumber) else { - return false - } - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.function, .capsLock]) - return flags == [.numericPad] - } - - func isTraditionalZhuyinInputSource(_ sourceId: String?) -> Bool { - guard let sourceId else { return false } - return sourceId.localizedCaseInsensitiveContains("TCIM.Zhuyin") - } - - func shouldOpenZhuyinCandidatesWithSyntheticSpace( - event: NSEvent, - inputSourceId: String?, - markedTextBefore: Bool, - before: (text: String, selection: NSRange), - after: (text: String, selection: NSRange), - accumulatedText: [String], - commandSelector: Selector?, - candidateOpenAlreadyRequested: Bool - ) -> Bool { - guard !candidateOpenAlreadyRequested, - markedTextBefore, - accumulatedText.isEmpty, - isTraditionalZhuyinInputSource(inputSourceId), - Int(event.keyCode) == kVK_DownArrow, - commandSelector == #selector(NSResponder.moveDown(_:)), - before.text == after.text, - before.selection == after.selection else { - return false - } - return true - } - - func shouldRememberZhuyinCandidateInteraction( - event: NSEvent, - inputSourceId: String?, - markedTextBefore: Bool, - accumulatedText: [String] - ) -> Bool { - guard markedTextBefore, - accumulatedText.isEmpty, - isTraditionalZhuyinInputSource(inputSourceId) else { - return false - } - - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags.isEmpty || flags == [.shift] else { return false } - - switch Int(event.keyCode) { - case kVK_DownArrow, kVK_UpArrow, kVK_PageUp, kVK_PageDown, kVK_Space: - return true - default: - return false - } - } - - /// Returns true when a window-level key-equivalent probe should re-enter - /// the terminal's keyDown path so AppKit's text input context sees the key - /// before terminal bindings or cursor escape sequences do. - func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool { - shouldRouteTextInputKeyEquivalentToKeyDown(event, inputSourceId: nil) - } - - func shouldRouteTextInputKeyEquivalentToKeyDown(_ event: NSEvent, inputSourceId: String?) -> Bool { - guard event.type == .keyDown else { return false } - guard shouldKeepIMECompositionCommandInsideTextInput(event) else { return false } - if hasMarkedText() { return true } - return isInputMethodSource(inputSourceId ?? KeyboardLayout.id) - } - - /// Returns true for active-composition command keys that belong to AppKit's - /// text input manager even when marked text itself does not change. - func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags.isEmpty || flags == [.shift] else { return false } - - switch Int(event.keyCode) { - case kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow, - kVK_PageUp, kVK_PageDown, kVK_Home, kVK_End, - kVK_Space, kVK_Return, kVK_ANSI_KeypadEnter, kVK_Escape, - kVK_Tab, kVK_Delete, kVK_ForwardDelete: - return true - default: - return false - } + return before.selection != after.selection } #if DEBUG @@ -177,18 +54,12 @@ extension GhosttyNSView { markedSelectionBefore: NSRange, markedTextAfter: String, markedSelectionAfter: NSRange, - accumulatedText: [String], - event: NSEvent? = nil, - textInputHandledEvent: Bool = false, - inputSourceId: String? = nil + accumulatedText: [String] ) -> Bool { shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (markedTextBefore, markedSelectionBefore), after: (markedTextAfter, markedSelectionAfter), - accumulatedText: accumulatedText, - event: event, - textInputHandledEvent: textInputHandledEvent, - inputSourceId: inputSourceId + accumulatedText: accumulatedText ) } #endif diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3e17a159c0c5..2b312115cd1a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7030,8 +7030,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if result { desiredFocus = false terminalSurface?.recordExternalFocusState(false) - imeSuppressedKeyUpKeyCodes.removeAll() - zhuyinCandidateOpenRequested = false } if result, let surface = surface { let now = CACurrentMediaTime() @@ -7049,9 +7047,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastPerformKeyEvent: TimeInterval? private(set) var externalCommittedTextDepth = 0 var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator() - private var imeSuppressedKeyUpKeyCodes: Set = [] - private var textInputCommandSelectorDuringKeyDown: Selector? - private var zhuyinCandidateOpenRequested = false private struct SelectionSnapshot { let range: NSRange let string: String @@ -7060,22 +7055,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG // Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior. - func setKeyTextAccumulatorForTesting(_ value: [String]?) { keyTextAccumulator = value } - var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } - static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)? - - func setIMETransientStateForTesting( - suppressedKeyUpKeyCodes: Set, - zhuyinCandidateOpenRequested: Bool - ) { - imeSuppressedKeyUpKeyCodes = suppressedKeyUpKeyCodes - self.zhuyinCandidateOpenRequested = zhuyinCandidateOpenRequested - } - var imeSuppressedKeyUpKeyCodesForTesting: Set { - imeSuppressedKeyUpKeyCodes + func setKeyTextAccumulatorForTesting(_ value: [String]?) { + keyTextAccumulator = value } - var zhuyinCandidateOpenRequestedForTesting: Bool { - zhuyinCandidateOpenRequested + var keyTextAccumulatorForTesting: [String]? { + keyTextAccumulator } func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool { shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore) @@ -7095,15 +7079,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { - textInputCommandSelectorDuringKeyDown = selector -#if DEBUG - if hasMarkedText() { - cmuxDebugLog( - "ime.doCommand selector=\(NSStringFromSelector(selector)) " + - "markedLength=\(markedText.length)" - ) - } -#endif // Intentionally empty - prevents system beep on unhandled key commands } @@ -7482,21 +7457,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Set up text accumulator for interpretKeyEvents keyTextAccumulator = [] defer { keyTextAccumulator = nil } - textInputCommandSelectorDuringKeyDown = nil let markedTextBefore = markedText.length > 0 let markedStateBefore = (markedText.string, markedSelectedRange) // Capture the keyboard layout ID before interpretation so we can // detect if an IME changed it (e.g. toggling input methods). - let keyboardIdBefore = KeyboardLayout.id + // We only check when not already in a preedit state. + let keyboardIdBefore: String? = if (!markedTextBefore) { + KeyboardLayout.id + } else { + nil + } // Let the input system handle the event (for IME, dead keys, etc.) #if DEBUG let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif - let textInputHandledEvent = handleTextInputKeyEvent(translationEvent) + interpretKeyEvents([translationEvent]) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( @@ -7528,46 +7507,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 #endif - var accumulatedText = keyTextAccumulator ?? [] - var markedStateAfter = (markedText.string, markedSelectedRange) - if shouldOpenZhuyinCandidatesWithSyntheticSpace( - event: translationEvent, - inputSourceId: keyboardIdBefore, - markedTextBefore: markedTextBefore, - before: markedStateBefore, - after: markedStateAfter, - accumulatedText: accumulatedText, - commandSelector: textInputCommandSelectorDuringKeyDown, - candidateOpenAlreadyRequested: zhuyinCandidateOpenRequested - ) { - zhuyinCandidateOpenRequested = true - textInputCommandSelectorDuringKeyDown = nil - _ = handleTextInputKeyEvent(zhuyinCandidateOpenSpaceEvent(from: translationEvent)) - syncPreedit(clearIfNeeded: markedTextBefore) - accumulatedText = keyTextAccumulator ?? [] - markedStateAfter = (markedText.string, markedSelectedRange) - } else if shouldRememberZhuyinCandidateInteraction( - event: translationEvent, - inputSourceId: keyboardIdBefore, - markedTextBefore: markedTextBefore, - accumulatedText: accumulatedText - ) { - zhuyinCandidateOpenRequested = true - } else if markedTextBefore, isTraditionalZhuyinInputSource(keyboardIdBefore) { - zhuyinCandidateOpenRequested = false - } - + let accumulatedText = keyTextAccumulator ?? [] if shouldSuppressGhosttyKeyForwardingAfterIMEHandling( - before: markedStateBefore, - after: markedStateAfter, - accumulatedText: accumulatedText, - event: translationEvent, - textInputHandledEvent: textInputHandledEvent, - inputSourceId: keyboardIdBefore - ) { - imeSuppressedKeyUpKeyCodes.insert(event.keyCode) - return - } + before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText + ) { return } // Build the key event var keyEvent = ghostty_input_key_s() @@ -7749,35 +7692,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Rendering is driven by Ghostty's wakeups/renderer. } - @discardableResult - private func handleTextInputKeyEvent(_ event: NSEvent) -> Bool { -#if DEBUG - if let debugTextInputEventHandler = Self.debugTextInputEventHandler { - return debugTextInputEventHandler(self, event) - } -#endif - guard let inputContext else { - interpretKeyEvents([event]) - return false - } - return inputContext.handleEvent(event) - } - - private func zhuyinCandidateOpenSpaceEvent(from event: NSEvent) -> NSEvent { - NSEvent.keyEvent( - with: event.type, - location: event.locationInWindow, - modifierFlags: event.modifierFlags.subtracting([.shift, .numericPad, .function]), - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: event.isARepeat, - keyCode: UInt16(kVK_Space) - ) ?? event - } - @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG @@ -7810,10 +7724,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif override func keyUp(with event: NSEvent) { - if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil { - return - } - guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) return @@ -12834,8 +12744,8 @@ extension GhosttyNSView: NSTextInputClient { } func unmarkText() { - let hadMarkedText = markedText.length > 0 #if DEBUG + let hadMarkedText = markedText.length > 0 let typingTimingStart = CmuxTypingTiming.start() defer { CmuxTypingTiming.logDuration( @@ -12845,11 +12755,9 @@ extension GhosttyNSView: NSTextInputClient { ) } #endif - markedText.mutableString.setString("") - markedSelectedRange = NSRange(location: NSNotFound, length: 0) - zhuyinCandidateOpenRequested = false - - if hadMarkedText { + if markedText.length > 0 { + markedText.mutableString.setString("") + markedSelectedRange = NSRange(location: NSNotFound, length: 0) syncPreedit() invalidateTextInputCoordinates(selectionChanged: true) } diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 81a784d22fe7..b3ccb265b59a 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -16,6 +16,13 @@ private var ghosttyPasteAsPlainTextActionSwizzled = false private var ghosttyPasteAsPlainTextActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { + @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { + if let hook = cjkIMEInterpretKeyEventsHook, hook(self, eventArray) { + return + } + cmuxUnitTest_interpretKeyEvents(eventArray) + } + @objc func cmuxUnitTest_paste(_ sender: Any?) { ghosttyPasteActionHook?(self, sender) cmuxUnitTest_paste(sender) @@ -30,12 +37,30 @@ private extension GhosttyNSView { func installCJKIMEInterpretKeyEventsSwizzle() { guard !cjkIMEInterpretKeyEventsSwizzled else { return } - GhosttyNSView.debugTextInputEventHandler = { candidateView, event in - if let hook = cjkIMEInterpretKeyEventsHook, hook(candidateView, [event]) { - return true - } - candidateView.interpretKeyEvents([event]) - return false + let originalSelector = #selector(GhosttyNSView.interpretKeyEvents(_:)) + let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_interpretKeyEvents(_:)) + + guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { + fatalError("Unable to locate GhosttyNSView interpretKeyEvents methods for swizzling") + } + + let didAddMethod = class_addMethod( + GhosttyNSView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + GhosttyNSView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) } cjkIMEInterpretKeyEventsSwizzled = true diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index 26692d0c29d5..982956c1a7ec 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -1,6 +1,5 @@ import XCTest import AppKit -import Carbon.HIToolbox #if canImport(cmux_DEV) @testable import cmux_DEV @@ -56,17 +55,11 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } - private func keyEvent( - type: NSEvent.EventType = .keyDown, - text: String, - keyCode: UInt16, - modifierFlags: NSEvent.ModifierFlags = [], - windowNumber: Int - ) throws -> NSEvent { + private func keyEvent(text: String, keyCode: UInt16, windowNumber: Int) throws -> NSEvent { try XCTUnwrap(NSEvent.keyEvent( - with: type, + with: .keyDown, location: .zero, - modifierFlags: modifierFlags, + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: windowNumber, context: nil, @@ -214,401 +207,6 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } - func testArrowNavigationStaysInsideZhuyinCandidateWindowDuringComposition() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - surfaceView.setMarkedText( - "ㄓㄨ", - selectedRange: NSRange(location: 2, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - installCJKIMEInterpretKeyEventsSwizzle() - cjkIMEInterpretKeyEventsHook = { candidateView, _ in - guard candidateView === surfaceView else { return false } - return true - } - - var forwardedPressKeyCodes: [UInt32] = [] - var forwardedReleaseKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressKeyCodes.append(keyEvent.keycode) - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseKeyCodes.append(keyEvent.keycode) - } - } - - let keyDown = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: keyDown) - surfaceView.keyUp(with: keyUp) - } - - XCTAssertTrue(surfaceView.hasMarkedText(), "Candidate navigation should leave composition active") - XCTAssertEqual( - forwardedPressKeyCodes, - [], - "Arrow keys consumed by the Zhuyin candidate window must not move the terminal cursor" - ) - XCTAssertEqual( - forwardedReleaseKeyCodes, - [], - "A suppressed IME arrow keyDown should not be followed by an unmatched Ghostty keyUp" - ) - } - - func testDownArrowCanOpenZhuyinCandidatesBeforeMarkedTextIsMirrored() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - installCJKIMEInterpretKeyEventsSwizzle() - cjkIMEInterpretKeyEventsHook = { candidateView, _ in - guard candidateView === surfaceView else { return false } - return true - } - - var forwardedPressKeyCodes: [UInt32] = [] - var forwardedReleaseKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressKeyCodes.append(keyEvent.keycode) - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseKeyCodes.append(keyEvent.keycode) - } - } - - let keyDown = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: keyDown) - surfaceView.keyUp(with: keyUp) - } - - XCTAssertFalse( - surfaceView.hasMarkedText(), - "Opening Zhuyin candidates can be handled by the IME before it mirrors marked text" - ) - XCTAssertEqual( - forwardedPressKeyCodes, - [], - "A Zhuyin-handled Down arrow must stay with AppKit so it can open the candidate list" - ) - XCTAssertEqual( - forwardedReleaseKeyCodes, - [], - "A Zhuyin-handled Down arrow keyUp must not leave an unmatched terminal release" - ) - } - - func testWindowKeyEquivalentRoutesInputMethodArrowThroughKeyDownBeforeMarkedText() throws { - _ = NSApplication.shared - AppDelegate.installWindowResponderSwizzlesForTesting() - - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - installCJKIMEInterpretKeyEventsSwizzle() - - var sawDownInTextInput = false - cjkIMEInterpretKeyEventsHook = { candidateView, events in - guard candidateView === surfaceView, let event = events.first else { return false } - guard Int(event.keyCode) == kVK_DownArrow else { return false } - sawDownInTextInput = true - return true - } - - var forwardedPressKeyCodes: [UInt32] = [] - var forwardedReleaseKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressKeyCodes.append(keyEvent.keycode) - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseKeyCodes.append(keyEvent.keycode) - } - } - - let keyDown = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - XCTAssertTrue( - window.performKeyEquivalent(with: keyDown), - "A window-level IME arrow key equivalent should be handled by routing it through terminal keyDown" - ) - surfaceView.keyUp(with: keyUp) - } - - XCTAssertTrue( - sawDownInTextInput, - "The window key-equivalent path must deliver Bopomofo candidate arrows to NSTextInputContext" - ) - XCTAssertEqual( - forwardedPressKeyCodes, - [], - "A Down arrow handled by the input method must not move the terminal cursor" - ) - XCTAssertEqual( - forwardedReleaseKeyCodes, - [], - "A suppressed IME keyDown must also suppress the paired keyUp" - ) - } - - func testArrowStillForwardsToTerminalWhenNoCompositionIsActive() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - var forwardedPressKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } - forwardedPressKeyCodes.append(keyEvent.keycode) - } - - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: event) - } - - XCTAssertFalse(surfaceView.hasMarkedText()) - XCTAssertEqual( - forwardedPressKeyCodes, - [UInt32(kVK_DownArrow)], - "Plain arrows should keep moving the terminal cursor when no IME composition is active" - ) - } - - func testDownArrowRequestsZhuyinCandidatesViaSpaceCommandFallback() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - surfaceView.setMarkedText( - "ㄓㄨ", - selectedRange: NSRange(location: 2, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - installCJKIMEInterpretKeyEventsSwizzle() - - var sawDownCommand = false - var syntheticSpaceCount = 0 - cjkIMEInterpretKeyEventsHook = { candidateView, events in - guard candidateView === surfaceView, let event = events.first else { return false } - if Int(event.keyCode) == kVK_DownArrow { - sawDownCommand = true - candidateView.doCommand(by: #selector(NSResponder.moveDown(_:))) - return false - } - if Int(event.keyCode) == kVK_Space { - syntheticSpaceCount += 1 - return true - } - return false - } - - var forwardedPressKeyCodes: [UInt32] = [] - var forwardedReleaseKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - if keyEvent.action == GHOSTTY_ACTION_PRESS { - forwardedPressKeyCodes.append(keyEvent.keycode) - } else if keyEvent.action == GHOSTTY_ACTION_RELEASE { - forwardedReleaseKeyCodes.append(keyEvent.keycode) - } - } - - let keyDown = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - let keyUp = try keyEvent( - type: .keyUp, - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: keyDown) - surfaceView.keyUp(with: keyUp) - } - - XCTAssertTrue(sawDownCommand) - XCTAssertEqual( - syntheticSpaceCount, - 1, - "A Zhuyin Down command that AppKit does not otherwise resolve should ask the IME to open candidates" - ) - XCTAssertTrue(surfaceView.hasMarkedText()) - XCTAssertEqual(forwardedPressKeyCodes, []) - XCTAssertEqual(forwardedReleaseKeyCodes, []) - } - - func testNumberCandidateSelectionStillCommitsTextDuringZhuyinComposition() throws { - let hostedTerminal = try makeHostedTerminalWindow() - let terminalSurface = hostedTerminal.surface - let window = hostedTerminal.window - let surfaceView = hostedTerminal.surfaceView - let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver - let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride - let previousInterpretHook = cjkIMEInterpretKeyEventsHook - defer { - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver - KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride - cjkIMEInterpretKeyEventsHook = previousInterpretHook - window.orderOut(nil) - withExtendedLifetime(terminalSurface) {} - } - - surfaceView.setMarkedText( - "ㄓㄨ", - selectedRange: NSRange(location: 2, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" - installCJKIMEInterpretKeyEventsSwizzle() - cjkIMEInterpretKeyEventsHook = { candidateView, _ in - guard candidateView === surfaceView else { return false } - candidateView.insertText("注", replacementRange: NSRange(location: NSNotFound, length: 0)) - return true - } - - var forwardedText: [String] = [] - var forwardedPressKeyCodes: [UInt32] = [] - GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in - previousKeyEventObserver?(keyEvent) - guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } - if let text = keyEvent.text { - forwardedText.append(String(cString: text)) - } else { - forwardedPressKeyCodes.append(keyEvent.keycode) - } - } - - let event = try keyEvent( - text: "1", - keyCode: UInt16(kVK_ANSI_1), - windowNumber: window.windowNumber - ) - - window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { - surfaceView.keyDown(with: event) - } - - XCTAssertFalse(surfaceView.hasMarkedText(), "Number candidate selection should commit the chosen text") - XCTAssertEqual(forwardedText, ["注"]) - XCTAssertEqual( - forwardedPressKeyCodes, - [], - "Candidate number selection should not also send the raw number key to the terminal" - ) - } - func testSuppressesTerminalForwardingWhenZhuyinMarkedTextChanges() { let view = GhosttyNSView(frame: .zero) @@ -650,108 +248,4 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) ) } - - func testDoesNotSuppressNonInputMethodDeadKeyCommandWhenMarkedTextIsUnchanged() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "`", - markedSelectionBefore: NSRange(location: 1, length: 0), - markedTextAfter: "`", - markedSelectionAfter: NSRange(location: 1, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.keylayout.USInternational" - ) - ) - } - - func testSuppressesInputMethodCommandWhenMarkedTextIsUnchanged() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "ㄓㄨ", - markedSelectionBefore: NSRange(location: 2, length: 0), - markedTextAfter: "ㄓㄨ", - markedSelectionAfter: NSRange(location: 2, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: false, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ) - ) - } - - func testUnmarkTextPreservesSuppressedKeyUpStateWithoutMarkedText() { - let view = GhosttyNSView(frame: .zero) - view.setIMETransientStateForTesting( - suppressedKeyUpKeyCodes: [UInt16(kVK_DownArrow)], - zhuyinCandidateOpenRequested: true - ) - - view.unmarkText() - - XCTAssertFalse(view.hasMarkedText()) - XCTAssertEqual(view.markedRange(), NSRange(location: NSNotFound, length: 0)) - XCTAssertEqual(view.imeSuppressedKeyUpKeyCodesForTesting, [UInt16(kVK_DownArrow)]) - XCTAssertFalse(view.zhuyinCandidateOpenRequestedForTesting) - } - - func testSuppressesInputMethodHandledKeyWithoutMarkedText() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "", - markedSelectionBefore: NSRange(location: NSNotFound, length: 0), - markedTextAfter: "", - markedSelectionAfter: NSRange(location: NSNotFound, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ) - ) - } - - func testAllowsDeferredNumpadFallbackWithoutMarkedText() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "1", - keyCode: UInt16(kVK_ANSI_Keypad1), - modifierFlags: [.numericPad], - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( - markedTextBefore: "", - markedSelectionBefore: NSRange(location: NSNotFound, length: 0), - markedTextAfter: "", - markedSelectionAfter: NSRange(location: NSNotFound, length: 0), - accumulatedText: [], - event: event, - textInputHandledEvent: true, - inputSourceId: "com.apple.inputmethod.TCIM.Pinyin" - ) - ) - } }