From 7e1a566814c2c54734358c2747e3ea4e034ef823 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 03:49:58 -0700 Subject: [PATCH 01/10] Add Korean 2-Set arrow IME regression --- cmuxTests/CJKIMEMarkedSelectionTests.swift | 90 ++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index 982956c1a7ec..75464d8dba96 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import Carbon.HIToolbox #if canImport(cmux_DEV) @testable import cmux_DEV @@ -70,6 +71,13 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { )) } + private struct KoreanArrowProbe { + let text: String + let keyCode: UInt16 + let selectionBefore: NSRange + let selectionAfter: NSRange + } + func testSelectedRangeReturnsEmptyRangeWithoutSelectionOrMarkedText() { let view = GhosttyNSView(frame: .zero) let range = view.selectedRange() @@ -207,6 +215,88 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } + func testKeyDownForKoreanPostCompositionHorizontalArrowsForwardsToTerminal() 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) {} + } + + let probes = [ + KoreanArrowProbe( + text: "\u{F702}", + keyCode: UInt16(kVK_LeftArrow), + selectionBefore: NSRange(location: 5, length: 0), + selectionAfter: NSRange(location: 4, length: 0) + ), + KoreanArrowProbe( + text: "\u{F703}", + keyCode: UInt16(kVK_RightArrow), + selectionBefore: NSRange(location: 4, length: 0), + selectionAfter: NSRange(location: 5, length: 0) + ), + ] + var selectionAfterByKeyCode: [UInt16: NSRange] = [:] + for probe in probes { + selectionAfterByKeyCode[probe.keyCode] = probe.selectionAfter + } + + KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.Korean.2SetKorean" + installCJKIMEInterpretKeyEventsSwizzle() + cjkIMEInterpretKeyEventsHook = { candidateView, events in + guard candidateView === surfaceView, + let event = events.first, + let selectionAfter = selectionAfterByKeyCode[event.keyCode] else { + return false + } + candidateView.setMarkedText( + "안녕하세요", + selectedRange: selectionAfter, + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + return true + } + + var forwardedPressKeyCodes: [UInt32] = [] + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } + forwardedPressKeyCodes.append(keyEvent.keycode) + } + + window.makeFirstResponder(surfaceView) + withExtendedLifetime(terminalSurface) { + for probe in probes { + surfaceView.setMarkedText( + "안녕하세요", + selectedRange: probe.selectionBefore, + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + let event = try keyEvent( + text: probe.text, + keyCode: probe.keyCode, + windowNumber: window.windowNumber + ) + surfaceView.keyDown(with: event) + } + } + + XCTAssertEqual( + forwardedPressKeyCodes, + probes.map { UInt32($0.keyCode) }, + "Korean 2-Set Left/Right after Hangul composition should reach the terminal cursor path" + ) + } + func testSuppressesTerminalForwardingWhenZhuyinMarkedTextChanges() { let view = GhosttyNSView(frame: .zero) From f206799d37559c59386d121b05b0c6efb4ee8c53 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 03:50:06 -0700 Subject: [PATCH 02/10] Restore Korean 2-Set arrow forwarding --- Sources/AppDelegate.swift | 8 +++ Sources/GhosttyNSView+IMEComposition.swift | 59 ++++++++++++++++++++-- Sources/GhosttyTerminalView.swift | 21 ++++---- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f224610e3be1..a8ec05911e64 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14705,6 +14705,14 @@ private extension NSWindow { } if let ghosttyView = firstResponderGhosttyView { + if ghosttyView.shouldRouteKoreanMarkedSelectionArrowKeyEquivalentToKeyDown(event) { + ghosttyView.keyDown(with: event) +#if DEBUG + cmuxDebugLog(" → Korean marked-selection arrow routed to terminal 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 dc51ba0abfe9..e1be73f5b8cb 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon.HIToolbox extension GhosttyNSView { /// Clamps AppKit's marked-text selection into the active preedit buffer. @@ -33,7 +34,9 @@ extension GhosttyNSView { func shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (text: String, selection: NSRange), after: (text: String, selection: NSRange), - accumulatedText: [String] + accumulatedText: [String], + event: NSEvent? = nil, + inputSourceId: String? = nil ) -> Bool { guard accumulatedText.isEmpty else { return false } @@ -45,7 +48,51 @@ extension GhosttyNSView { return true } - return before.selection != after.selection + if before.selection != after.selection { + return !shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: event, + inputSourceId: inputSourceId + ) + } + + return false + } + + func shouldRouteKoreanMarkedSelectionArrowKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool { + guard hasMarkedText() else { return false } + return shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: event, + inputSourceId: KeyboardLayout.id + ) + } + + private func shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: NSEvent?, + inputSourceId: String? + ) -> Bool { + guard let event else { return false } + guard isKorean2SetInputSource(inputSourceId) else { return false } + guard hasOnlyPlainTextInputModifiers(event) else { return false } + + switch Int(event.keyCode) { + case kVK_LeftArrow, kVK_RightArrow: + return true + default: + return false + } + } + + private func isKorean2SetInputSource(_ inputSourceId: String?) -> Bool { + guard let inputSourceId else { return false } + return inputSourceId.localizedCaseInsensitiveContains("Korean.2Set") + || inputSourceId.localizedCaseInsensitiveContains("2SetKorean") + } + + private func hasOnlyPlainTextInputModifiers(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + return flags.isEmpty } #if DEBUG @@ -54,12 +101,16 @@ extension GhosttyNSView { markedSelectionBefore: NSRange, markedTextAfter: String, markedSelectionAfter: NSRange, - accumulatedText: [String] + accumulatedText: [String], + event: NSEvent? = nil, + inputSourceId: String? = nil ) -> Bool { shouldSuppressGhosttyKeyForwardingAfterIMEHandling( before: (markedTextBefore, markedSelectionBefore), after: (markedTextAfter, markedSelectionAfter), - accumulatedText: accumulatedText + accumulatedText: accumulatedText, + event: event, + inputSourceId: inputSourceId ) } #endif diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d4adba210c64..11816f1c8c58 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7709,14 +7709,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { 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). - // We only check when not already in a preedit state. - let keyboardIdBefore: String? = if (!markedTextBefore) { - KeyboardLayout.id - } else { - nil - } + // Capture the keyboard layout ID before interpretation so the IME + // forwarding decision uses the source that saw this key. + let keyboardIdBefore = KeyboardLayout.id // Let the input system handle the event (for IME, dead keys, etc.) #if DEBUG @@ -7758,12 +7753,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let accumulatedText = keyTextAccumulator ?? [] if shouldSuppressGhosttyKeyForwardingAfterIMEHandling( - before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText + before: markedStateBefore, + after: (markedText.string, markedSelectedRange), + accumulatedText: accumulatedText, + event: translationEvent, + inputSourceId: keyboardIdBefore ) { imeConsumedKeyUps.insert(event.keyCode) return } + // A forwarded keyDown owns its keyUp. Clear any stale IME suppression + // entry left by an earlier suppressed repeat for the same physical key. + imeConsumedKeyUps.remove(event.keyCode) + // Build the key event var keyEvent = ghostty_input_key_s() keyEvent.action = action From 64176c6d1a7e618d886c5db39333faccd2814977 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 04:02:38 -0700 Subject: [PATCH 03/10] Restore Zhuyin IME command routing --- Sources/AppDelegate.swift | 6 +- Sources/GhosttyNSView+IMEComposition.swift | 106 +++++++++++++++-- Sources/GhosttyTerminalView.swift | 45 ++++++- cmuxTests/CJKIMEMarkedSelectionTests.swift | 132 +++++++++++++++++++++ 4 files changed, 277 insertions(+), 12 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a8ec05911e64..46036676b064 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14705,10 +14705,10 @@ private extension NSWindow { } if let ghosttyView = firstResponderGhosttyView { - if ghosttyView.shouldRouteKoreanMarkedSelectionArrowKeyEquivalentToKeyDown(event) { + if ghosttyView.shouldRouteTextInputKeyEquivalentToKeyDown(event) { ghosttyView.keyDown(with: event) #if DEBUG - cmuxDebugLog(" → Korean marked-selection arrow routed to terminal keyDown") + cmuxDebugLog(" → terminal text-input key equivalent routed to keyDown") #endif return true } @@ -14718,7 +14718,7 @@ private extension NSWindow { // process it. Cmd-based shortcuts should still work during composition since // Cmd is never part of IME input sequences. if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { - return cmux_performKeyEquivalent(with: event) + return false } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index e1be73f5b8cb..b343ca7ea9e2 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -36,13 +36,17 @@ extension GhosttyNSView { after: (text: String, selection: NSRange), accumulatedText: [String], event: NSEvent? = nil, + textInputHandledEvent: Bool = false, inputSourceId: String? = nil ) -> Bool { guard accumulatedText.isEmpty else { return false } let hadMarkedTextBefore = !before.text.isEmpty let hasMarkedTextAfter = !after.text.isEmpty - guard hadMarkedTextBefore || hasMarkedTextAfter else { return false } + guard hadMarkedTextBefore || hasMarkedTextAfter else { + guard textInputHandledEvent, isBopomofoInputSource(inputSourceId) else { return false } + return shouldKeepNoMarkedIMECommandInsideTextInput(event) + } if before.text != after.text { return true @@ -55,15 +59,36 @@ extension GhosttyNSView { ) } - return false + guard let event, isInputMethodSource(inputSourceId) else { + return false + } + guard !shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: event, + inputSourceId: inputSourceId + ) else { + return false + } + return shouldKeepIMECompositionCommandInsideTextInput(event) } - func shouldRouteKoreanMarkedSelectionArrowKeyEquivalentToKeyDown(_ event: NSEvent) -> Bool { - guard hasMarkedText() else { return false } - return shouldForwardKoreanMarkedSelectionArrowToTerminal( - event: event, - inputSourceId: KeyboardLayout.id - ) + /// 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 } + let resolvedInputSourceId = inputSourceId ?? KeyboardLayout.id + if hasMarkedText() { + return false + } + return isBopomofoInputSource(resolvedInputSourceId) + && shouldKeepNoMarkedIMECommandInsideTextInput(event) } private func shouldForwardKoreanMarkedSelectionArrowToTerminal( @@ -88,6 +113,17 @@ extension GhosttyNSView { || inputSourceId.localizedCaseInsensitiveContains("2SetKorean") } + private func isInputMethodSource(_ inputSourceId: String?) -> Bool { + guard let inputSourceId else { return false } + return inputSourceId.localizedCaseInsensitiveContains("inputmethod") + } + + private func isBopomofoInputSource(_ inputSourceId: String?) -> Bool { + guard let inputSourceId else { return false } + return inputSourceId.localizedCaseInsensitiveContains("Zhuyin") + || inputSourceId.localizedCaseInsensitiveContains("Bopomofo") + } + private func hasOnlyPlainTextInputModifiers(_ event: NSEvent) -> Bool { let flags = event.modifierFlags .intersection(.deviceIndependentFlagsMask) @@ -95,6 +131,58 @@ extension GhosttyNSView { return flags.isEmpty } + /// Returns true for active-composition command keys that belong to AppKit's + /// text input manager even when marked text itself does not change. + private func shouldKeepIMECompositionCommandInsideTextInput(_ event: NSEvent) -> Bool { + guard hasOnlyTextInputCommandModifiers(event) 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 + } + } + + private func hasOnlyTextInputCommandModifiers(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + return flags.isEmpty || flags == [.shift] + } + + private func shouldKeepNoMarkedIMECommandInsideTextInput(_ event: NSEvent?) -> Bool { + guard let event else { return false } + guard hasOnlyTextInputCommandModifiers(event) else { return false } + + switch Int(event.keyCode) { + case kVK_DownArrow, kVK_PageUp, kVK_PageDown, kVK_Space: + return true + default: + return false + } + } + + func shouldBufferBopomofoInsertedPreedit(_ text: String, inputSourceId: String? = nil) -> Bool { + guard !text.isEmpty else { return false } + guard isBopomofoInputSource(inputSourceId ?? KeyboardLayout.id) else { return false } + return text.unicodeScalars.allSatisfy(isBopomofoPreeditScalar) + } + + private func isBopomofoPreeditScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x3100...0x312F, 0x31A0...0x31BF: + return true + case 0x02C7, 0x02C9, 0x02CA, 0x02CB, 0x02D9: + return true + default: + return false + } + } + #if DEBUG func shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( markedTextBefore: String, @@ -103,6 +191,7 @@ extension GhosttyNSView { markedSelectionAfter: NSRange, accumulatedText: [String], event: NSEvent? = nil, + textInputHandledEvent: Bool = false, inputSourceId: String? = nil ) -> Bool { shouldSuppressGhosttyKeyForwardingAfterIMEHandling( @@ -110,6 +199,7 @@ extension GhosttyNSView { after: (markedTextAfter, markedSelectionAfter), accumulatedText: accumulatedText, event: event, + textInputHandledEvent: textInputHandledEvent, inputSourceId: inputSourceId ) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 11816f1c8c58..3f56bac5ef3c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6183,6 +6183,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? + @MainActor static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -7718,7 +7719,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif - interpretKeyEvents([translationEvent]) + let textInputHandledEvent = handleTextInputKeyEvent(translationEvent) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( @@ -7757,6 +7758,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText, event: translationEvent, + textInputHandledEvent: textInputHandledEvent, inputSourceId: keyboardIdBefore ) { imeConsumedKeyUps.insert(event.keyCode) @@ -7947,6 +7949,21 @@ 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 inputContext != nil else { + interpretKeyEvents([event]) + return false + } + interpretKeyEvents([event]) + return true + } + @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG @@ -13207,6 +13224,12 @@ extension GhosttyNSView: NSTextInputClient { return } + if keyTextAccumulator != nil, + shouldBufferBopomofoInsertedPreedit(chars) { + insertBopomofoPreeditText(chars, replacementRange: replacementRange) + return + } + // Clear marked text since we're inserting unmarkText() @@ -13257,6 +13280,26 @@ extension GhosttyNSView: NSTextInputClient { preserveLiteralEscape: !isExternalCommittedText ) } + + private func insertBopomofoPreeditText(_ chars: String, replacementRange: NSRange) { + if replacementRange.location != NSNotFound, + let range = Range(replacementRange, in: markedText.string) { + let next = markedText.string.replacingCharacters(in: range, with: chars) + markedText = NSMutableAttributedString(string: next) + let location = markedText.string.distance(from: markedText.string.startIndex, to: range.lowerBound) + chars.count + markedSelectedRange = normalizedMarkedSelectionRange( + NSRange(location: location, length: 0), + markedLength: markedText.length + ) + return + } + + markedText.append(NSAttributedString(string: chars)) + markedSelectedRange = normalizedMarkedSelectionRange( + NSRange(location: markedText.length, length: 0), + markedLength: markedText.length + ) + } } // MARK: - SwiftUI Wrapper diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index 75464d8dba96..3db4332c69d5 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -297,6 +297,138 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } + func testDoesNotConsumeZhuyinMarkedTextDownArrowAsKeyEquivalent() throws { + let view = GhosttyNSView(frame: .zero) + view.setMarkedText( + "ㄓㄨ", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + let event = try keyEvent( + text: "\u{F701}", + keyCode: UInt16(kVK_DownArrow), + windowNumber: 0 + ) + + XCTAssertFalse( + view.shouldRouteTextInputKeyEquivalentToKeyDown( + event, + inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" + ), + "Zhuyin Down while marked text is active should stay on AppKit's normal keyDown dispatch path" + ) + } + + func testSuppressesZhuyinMarkedTextDownArrowAfterTextInputHandling() 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: true, + inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" + ), + "Zhuyin Down belongs to the IME candidate menu and should not also move the terminal cursor" + ) + } + + func testRoutesZhuyinNoMarkedDownArrowThroughKeyDown() throws { + let view = GhosttyNSView(frame: .zero) + let event = try keyEvent( + text: "\u{F701}", + keyCode: UInt16(kVK_DownArrow), + windowNumber: 0 + ) + + XCTAssertTrue( + view.shouldRouteTextInputKeyEquivalentToKeyDown( + event, + inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" + ), + "Zhuyin Down should reach AppKit text input even when AppKit has not exposed marked text" + ) + } + + func testSuppressesZhuyinNoMarkedDownArrowAfterTextInputHandling() 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" + ), + "A Zhuyin Down key consumed by text input must not also send terminal Down" + ) + } + + func testBuffersZhuyinComponentInsertTextAsPreedit() { + let view = GhosttyNSView(frame: .zero) + let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride + defer { + KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride + view.setKeyTextAccumulatorForTesting(nil) + } + + KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" + view.setKeyTextAccumulatorForTesting([]) + + view.insertText("ㄉ", replacementRange: NSRange(location: NSNotFound, length: 0)) + view.insertText("ㄚ", replacementRange: NSRange(location: NSNotFound, length: 0)) + view.insertText("ˋ", replacementRange: NSRange(location: NSNotFound, length: 0)) + + XCTAssertTrue(view.hasMarkedText(), "Zhuyin components inserted by Apple IME should stay in editable preedit") + XCTAssertEqual(view.attributedString().string, "ㄉㄚˋ") + XCTAssertEqual(view.selectedRange(), NSRange(location: 3, length: 0)) + XCTAssertEqual( + view.keyTextAccumulatorForTesting, + [], + "Raw Zhuyin components must not be committed to the terminal before candidate selection" + ) + } + + func testCommittedZhuyinCandidateStillReachesTerminalAccumulator() { + let view = GhosttyNSView(frame: .zero) + let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride + defer { + KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride + view.setKeyTextAccumulatorForTesting(nil) + } + + KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" + view.setKeyTextAccumulatorForTesting([]) + view.setMarkedText( + "ㄉㄚˋ", + selectedRange: NSRange(location: 3, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + view.insertText("大", replacementRange: NSRange(location: 0, length: 3)) + + XCTAssertFalse(view.hasMarkedText(), "Committed Zhuyin candidate should end preedit") + XCTAssertEqual(view.keyTextAccumulatorForTesting, ["大"]) + } + func testSuppressesTerminalForwardingWhenZhuyinMarkedTextChanges() { let view = GhosttyNSView(frame: .zero) From e2fa96666d15adf8307b04ad9a59d0e8575f8c7b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 04:45:42 -0700 Subject: [PATCH 04/10] Address IME review feedback --- Sources/GhosttyNSView+IMEComposition.swift | 7 +++++-- cmuxTests/CJKIMEMarkedSelectionTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index b343ca7ea9e2..3299749dfd38 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -2,6 +2,10 @@ import AppKit import Carbon.HIToolbox extension GhosttyNSView { + private static let korean2SetInputSourceIDs: Set = [ + "com.apple.inputmethod.Korean.2SetKorean", + ] + /// Clamps AppKit's marked-text selection into the active preedit buffer. func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange { guard markedLength > 0 else { @@ -109,8 +113,7 @@ extension GhosttyNSView { private func isKorean2SetInputSource(_ inputSourceId: String?) -> Bool { guard let inputSourceId else { return false } - return inputSourceId.localizedCaseInsensitiveContains("Korean.2Set") - || inputSourceId.localizedCaseInsensitiveContains("2SetKorean") + return Self.korean2SetInputSourceIDs.contains(inputSourceId) } private func isInputMethodSource(_ inputSourceId: String?) -> Bool { diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index 3db4332c69d5..ebf79c9d4072 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -297,6 +297,28 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } + func testDoesNotRerouteKoreanMarkedSelectionArrowKeyEquivalent() throws { + let view = GhosttyNSView(frame: .zero) + view.setMarkedText( + "안녕하세요", + selectedRange: NSRange(location: 5, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + let event = try keyEvent( + text: "\u{F702}", + keyCode: UInt16(kVK_LeftArrow), + windowNumber: 0 + ) + + XCTAssertFalse( + view.shouldRouteTextInputKeyEquivalentToKeyDown( + event, + inputSourceId: "com.apple.inputmethod.Korean.2SetKorean" + ), + "Korean 2-Set Left/Right with active marked text should stay on AppKit's normal keyDown dispatch path" + ) + } + func testDoesNotConsumeZhuyinMarkedTextDownArrowAsKeyEquivalent() throws { let view = GhosttyNSView(frame: .zero) view.setMarkedText( From 9795cb4480f532d1d2be0d92c9ca16287d668598 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 04:52:59 -0700 Subject: [PATCH 05/10] Fix Korean IME regression test compile --- cmuxTests/CJKIMEMarkedSelectionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index ebf79c9d4072..eca53e1411da 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -274,7 +274,7 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { } window.makeFirstResponder(surfaceView) - withExtendedLifetime(terminalSurface) { + try withExtendedLifetime(terminalSurface) { for probe in probes { surfaceView.setMarkedText( "안녕하세요", From 2090f4dee21cb2d5d5913414663ccf9f5f44844d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 05:08:30 -0700 Subject: [PATCH 06/10] Address IME review follow-ups --- Sources/GhosttyNSView+IMEComposition.swift | 6 +++++- Sources/GhosttyTerminalView.swift | 4 ++-- cmuxTests/CJKIMEMarkedSelectionTests.swift | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index 3299749dfd38..70c90683b4d0 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -118,7 +118,11 @@ extension GhosttyNSView { private func isInputMethodSource(_ inputSourceId: String?) -> Bool { guard let inputSourceId else { return false } - return inputSourceId.localizedCaseInsensitiveContains("inputmethod") + return inputSourceId.range( + of: ".inputmethod.", + options: .caseInsensitive, + locale: Locale(identifier: "en_US_POSIX") + ) != nil } private func isBopomofoInputSource(_ inputSourceId: String?) -> Bool { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3f56bac5ef3c..9bba3e56df82 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -13284,11 +13284,11 @@ extension GhosttyNSView: NSTextInputClient { private func insertBopomofoPreeditText(_ chars: String, replacementRange: NSRange) { if replacementRange.location != NSNotFound, let range = Range(replacementRange, in: markedText.string) { + let insertionLocation = replacementRange.location + (chars as NSString).length let next = markedText.string.replacingCharacters(in: range, with: chars) markedText = NSMutableAttributedString(string: next) - let location = markedText.string.distance(from: markedText.string.startIndex, to: range.lowerBound) + chars.count markedSelectedRange = normalizedMarkedSelectionRange( - NSRange(location: location, length: 0), + NSRange(location: insertionLocation, length: 0), markedLength: markedText.length ) return diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index eca53e1411da..ced6bb4705f4 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -250,6 +250,7 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { selectionAfterByKeyCode[probe.keyCode] = probe.selectionAfter } + AppDelegate.installWindowResponderSwizzlesForTesting() KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.Korean.2SetKorean" installCJKIMEInterpretKeyEventsSwizzle() cjkIMEInterpretKeyEventsHook = { candidateView, events in @@ -286,7 +287,7 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { keyCode: probe.keyCode, windowNumber: window.windowNumber ) - surfaceView.keyDown(with: event) + window.sendEvent(event) } } @@ -418,9 +419,10 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { view.insertText("ㄉ", replacementRange: NSRange(location: NSNotFound, length: 0)) view.insertText("ㄚ", replacementRange: NSRange(location: NSNotFound, length: 0)) view.insertText("ˋ", replacementRange: NSRange(location: NSNotFound, length: 0)) + view.insertText("ˊ", replacementRange: NSRange(location: 2, length: 1)) XCTAssertTrue(view.hasMarkedText(), "Zhuyin components inserted by Apple IME should stay in editable preedit") - XCTAssertEqual(view.attributedString().string, "ㄉㄚˋ") + XCTAssertEqual(view.attributedString().string, "ㄉㄚˊ") XCTAssertEqual(view.selectedRange(), NSRange(location: 3, length: 0)) XCTAssertEqual( view.keyTextAccumulatorForTesting, From f2a195574470ee20639c78eed85bb8a24df105d1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 05:28:07 -0700 Subject: [PATCH 07/10] Address Bopomofo preedit review feedback --- Sources/GhosttyNSView+IMEComposition.swift | 2 ++ Sources/GhosttyTerminalView.swift | 12 ++++++-- cmuxTests/CJKIMEMarkedSelectionTests.swift | 32 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift index 70c90683b4d0..c46f8e304051 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -2,6 +2,8 @@ import AppKit import Carbon.HIToolbox extension GhosttyNSView { + // Issue #4093 is specifically Korean 2-Set. Other Korean layouts should be + // validated separately before this allow-list is broadened. private static let korean2SetInputSourceIDs: Set = [ "com.apple.inputmethod.Korean.2SetKorean", ] diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9bba3e56df82..dab16e466b03 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -13282,9 +13282,9 @@ extension GhosttyNSView: NSTextInputClient { } private func insertBopomofoPreeditText(_ chars: String, replacementRange: NSRange) { - if replacementRange.location != NSNotFound, - let range = Range(replacementRange, in: markedText.string) { - let insertionLocation = replacementRange.location + (chars as NSString).length + let effectiveRange = effectiveBopomofoPreeditReplacementRange(replacementRange) + if let range = Range(effectiveRange, in: markedText.string) { + let insertionLocation = effectiveRange.location + (chars as NSString).length let next = markedText.string.replacingCharacters(in: range, with: chars) markedText = NSMutableAttributedString(string: next) markedSelectedRange = normalizedMarkedSelectionRange( @@ -13300,6 +13300,12 @@ extension GhosttyNSView: NSTextInputClient { markedLength: markedText.length ) } + + private func effectiveBopomofoPreeditReplacementRange(_ replacementRange: NSRange) -> NSRange { + guard replacementRange.location == NSNotFound else { return replacementRange } + guard markedText.length > 0 else { return NSRange(location: 0, length: 0) } + return normalizedMarkedSelectionRange(markedSelectedRange, markedLength: markedText.length) + } } // MARK: - SwiftUI Wrapper diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index ced6bb4705f4..bef186812f12 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -288,6 +288,11 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { windowNumber: window.windowNumber ) window.sendEvent(event) + XCTAssertEqual( + surfaceView.selectedRange(), + probe.selectionAfter, + "Korean 2-Set arrow handling should apply the IME marked-selection update" + ) } } @@ -431,6 +436,33 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } + func testBuffersZhuyinComponentInsertTextAtMarkedSelection() { + let view = GhosttyNSView(frame: .zero) + let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride + defer { + KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride + view.setKeyTextAccumulatorForTesting(nil) + } + + KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.TCIM.Zhuyin" + view.setKeyTextAccumulatorForTesting([]) + view.setMarkedText( + "ㄉㄚ", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + view.insertText("ㄅ", replacementRange: NSRange(location: NSNotFound, length: 0)) + + XCTAssertEqual(view.attributedString().string, "ㄉㄅㄚ") + XCTAssertEqual(view.selectedRange(), NSRange(location: 2, length: 0)) + XCTAssertEqual( + view.keyTextAccumulatorForTesting, + [], + "Raw Zhuyin insertion inside preedit should not commit to the terminal" + ) + } + func testCommittedZhuyinCandidateStillReachesTerminalAccumulator() { let view = GhosttyNSView(frame: .zero) let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride From 81a237ab077a954038f14e565f6804e38657e224 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 05:33:32 -0700 Subject: [PATCH 08/10] Avoid idle Zhuyin key suppression --- Sources/AppDelegate.swift | 8 -- Sources/GhosttyNSView+IMEComposition.swift | 45 +-------- Sources/GhosttyTerminalView.swift | 15 +-- cmuxTests/CJKIMEMarkedSelectionTests.swift | 104 +++++---------------- 4 files changed, 31 insertions(+), 141 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 46036676b064..0afdc615a327 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14705,14 +14705,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 c46f8e304051..0f4c60be13af 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -42,17 +42,13 @@ extension GhosttyNSView { after: (text: String, selection: NSRange), accumulatedText: [String], event: NSEvent? = nil, - textInputHandledEvent: Bool = false, inputSourceId: String? = nil ) -> Bool { guard accumulatedText.isEmpty else { return false } let hadMarkedTextBefore = !before.text.isEmpty let hasMarkedTextAfter = !after.text.isEmpty - guard hadMarkedTextBefore || hasMarkedTextAfter else { - guard textInputHandledEvent, isBopomofoInputSource(inputSourceId) else { return false } - return shouldKeepNoMarkedIMECommandInsideTextInput(event) - } + guard hadMarkedTextBefore || hasMarkedTextAfter else { return false } if before.text != after.text { return true @@ -77,26 +73,6 @@ extension GhosttyNSView { return shouldKeepIMECompositionCommandInsideTextInput(event) } - /// 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 } - let resolvedInputSourceId = inputSourceId ?? KeyboardLayout.id - if hasMarkedText() { - return false - } - return isBopomofoInputSource(resolvedInputSourceId) - && shouldKeepNoMarkedIMECommandInsideTextInput(event) - } - private func shouldForwardKoreanMarkedSelectionArrowToTerminal( event: NSEvent?, inputSourceId: String? @@ -129,8 +105,9 @@ extension GhosttyNSView { private func isBopomofoInputSource(_ inputSourceId: String?) -> Bool { guard let inputSourceId else { return false } - return inputSourceId.localizedCaseInsensitiveContains("Zhuyin") - || inputSourceId.localizedCaseInsensitiveContains("Bopomofo") + let comparisonLocale = Locale(identifier: "en_US_POSIX") + return inputSourceId.range(of: "Zhuyin", options: .caseInsensitive, locale: comparisonLocale) != nil + || inputSourceId.range(of: "Bopomofo", options: .caseInsensitive, locale: comparisonLocale) != nil } private func hasOnlyPlainTextInputModifiers(_ event: NSEvent) -> Bool { @@ -163,18 +140,6 @@ extension GhosttyNSView { return flags.isEmpty || flags == [.shift] } - private func shouldKeepNoMarkedIMECommandInsideTextInput(_ event: NSEvent?) -> Bool { - guard let event else { return false } - guard hasOnlyTextInputCommandModifiers(event) else { return false } - - switch Int(event.keyCode) { - case kVK_DownArrow, kVK_PageUp, kVK_PageDown, kVK_Space: - return true - default: - return false - } - } - func shouldBufferBopomofoInsertedPreedit(_ text: String, inputSourceId: String? = nil) -> Bool { guard !text.isEmpty else { return false } guard isBopomofoInputSource(inputSourceId ?? KeyboardLayout.id) else { return false } @@ -200,7 +165,6 @@ extension GhosttyNSView { markedSelectionAfter: NSRange, accumulatedText: [String], event: NSEvent? = nil, - textInputHandledEvent: Bool = false, inputSourceId: String? = nil ) -> Bool { shouldSuppressGhosttyKeyForwardingAfterIMEHandling( @@ -208,7 +172,6 @@ extension GhosttyNSView { after: (markedTextAfter, markedSelectionAfter), accumulatedText: accumulatedText, event: event, - textInputHandledEvent: textInputHandledEvent, inputSourceId: inputSourceId ) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index dab16e466b03..33779872ca1c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6183,7 +6183,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? - @MainActor static var debugTextInputEventHandler: ((GhosttyNSView, NSEvent) -> Bool)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -7719,7 +7718,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif - let textInputHandledEvent = handleTextInputKeyEvent(translationEvent) + handleTextInputKeyEvent(translationEvent) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( @@ -7758,7 +7757,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText, event: translationEvent, - textInputHandledEvent: textInputHandledEvent, inputSourceId: keyboardIdBefore ) { imeConsumedKeyUps.insert(event.keyCode) @@ -7949,19 +7947,12 @@ 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 + private func handleTextInputKeyEvent(_ event: NSEvent) { guard inputContext != nil else { interpretKeyEvents([event]) - return false + return } interpretKeyEvents([event]) - return true } @discardableResult diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index bef186812f12..b90838f3af12 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -303,50 +303,6 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } - func testDoesNotRerouteKoreanMarkedSelectionArrowKeyEquivalent() throws { - let view = GhosttyNSView(frame: .zero) - view.setMarkedText( - "안녕하세요", - selectedRange: NSRange(location: 5, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - let event = try keyEvent( - text: "\u{F702}", - keyCode: UInt16(kVK_LeftArrow), - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldRouteTextInputKeyEquivalentToKeyDown( - event, - inputSourceId: "com.apple.inputmethod.Korean.2SetKorean" - ), - "Korean 2-Set Left/Right with active marked text should stay on AppKit's normal keyDown dispatch path" - ) - } - - func testDoesNotConsumeZhuyinMarkedTextDownArrowAsKeyEquivalent() throws { - let view = GhosttyNSView(frame: .zero) - view.setMarkedText( - "ㄓㄨ", - selectedRange: NSRange(location: 2, length: 0), - replacementRange: NSRange(location: NSNotFound, length: 0) - ) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertFalse( - view.shouldRouteTextInputKeyEquivalentToKeyDown( - event, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ), - "Zhuyin Down while marked text is active should stay on AppKit's normal keyDown dispatch path" - ) - } - func testSuppressesZhuyinMarkedTextDownArrowAfterTextInputHandling() throws { let view = GhosttyNSView(frame: .zero) let event = try keyEvent( @@ -363,51 +319,39 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { markedSelectionAfter: NSRange(location: 2, length: 0), accumulatedText: [], event: event, - textInputHandledEvent: true, inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" ), "Zhuyin Down belongs to the IME candidate menu and should not also move the terminal cursor" ) } - func testRoutesZhuyinNoMarkedDownArrowThroughKeyDown() throws { + func testDoesNotSuppressIdleZhuyinNavigationKeyWithoutMarkedText() throws { let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) - - XCTAssertTrue( - view.shouldRouteTextInputKeyEquivalentToKeyDown( - event, - inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" - ), - "Zhuyin Down should reach AppKit text input even when AppKit has not exposed marked text" - ) - } + let probes: [(text: String, keyCode: UInt16)] = [ + ("\u{F701}", UInt16(kVK_DownArrow)), + (" ", UInt16(kVK_Space)), + ] - func testSuppressesZhuyinNoMarkedDownArrowAfterTextInputHandling() throws { - let view = GhosttyNSView(frame: .zero) - let event = try keyEvent( - text: "\u{F701}", - keyCode: UInt16(kVK_DownArrow), - windowNumber: 0 - ) + for probe in probes { + let event = try keyEvent( + text: probe.text, + keyCode: probe.keyCode, + 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" - ), - "A Zhuyin Down key consumed by text input must not also send terminal Down" - ) + XCTAssertFalse( + view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: "", + markedSelectionBefore: NSRange(location: NSNotFound, length: 0), + markedTextAfter: "", + markedSelectionAfter: NSRange(location: NSNotFound, length: 0), + accumulatedText: [], + event: event, + inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" + ), + "Idle Zhuyin navigation keys should still reach the terminal when no composition is active" + ) + } } func testBuffersZhuyinComponentInsertTextAsPreedit() { From afbfbe001a27bc2015636a815c9f0e5505ec58e8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 05:44:00 -0700 Subject: [PATCH 09/10] Remove dead text input wrapper --- Sources/GhosttyTerminalView.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 33779872ca1c..39b0fbd9a763 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7718,7 +7718,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let interpretTimingStart = CmuxTypingTiming.start() let interpretPhaseStart = ProcessInfo.processInfo.systemUptime #endif - handleTextInputKeyEvent(translationEvent) + interpretKeyEvents([translationEvent]) #if DEBUG interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 CmuxTypingTiming.logDuration( @@ -7947,14 +7947,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Rendering is driven by Ghostty's wakeups/renderer. } - private func handleTextInputKeyEvent(_ event: NSEvent) { - guard inputContext != nil else { - interpretKeyEvents([event]) - return - } - interpretKeyEvents([event]) - } - @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG From 50b0c0e2aab7eed760f74fb072b5532727caa201 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 05:58:08 -0700 Subject: [PATCH 10/10] Address IME suppression review feedback --- Sources/GhosttyTerminalView.swift | 2 +- cmuxTests/CJKIMEMarkedSelectionTests.swift | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 39b0fbd9a763..4ba6eb5a5d94 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7756,7 +7756,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText, - event: translationEvent, + event: event, inputSourceId: keyboardIdBefore ) { imeConsumedKeyUps.insert(event.keyCode) diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index b90838f3af12..86493cf8771d 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -16,8 +16,6 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { let surfaceView: GhosttyNSView } - deinit {} - private func makeHostedTerminalWindow() throws -> HostedTerminalWindow { _ = NSApplication.shared