diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f224610e3be1..0afdc615a327 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14710,7 +14710,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 dc51ba0abfe9..0f4c60be13af 100644 --- a/Sources/GhosttyNSView+IMEComposition.swift +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -1,6 +1,13 @@ 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", + ] + /// Clamps AppKit's marked-text selection into the active preedit buffer. func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange { guard markedLength > 0 else { @@ -33,7 +40,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 +54,107 @@ extension GhosttyNSView { return true } - return before.selection != after.selection + if before.selection != after.selection { + return !shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: event, + inputSourceId: inputSourceId + ) + } + + guard let event, isInputMethodSource(inputSourceId) else { + return false + } + guard !shouldForwardKoreanMarkedSelectionArrowToTerminal( + event: event, + inputSourceId: inputSourceId + ) else { + return false + } + return shouldKeepIMECompositionCommandInsideTextInput(event) + } + + 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 Self.korean2SetInputSourceIDs.contains(inputSourceId) + } + + private func isInputMethodSource(_ inputSourceId: String?) -> Bool { + guard let inputSourceId else { return false } + return inputSourceId.range( + of: ".inputmethod.", + options: .caseInsensitive, + locale: Locale(identifier: "en_US_POSIX") + ) != nil + } + + private func isBopomofoInputSource(_ inputSourceId: String?) -> Bool { + guard let inputSourceId else { return false } + 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 { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + 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] + } + + 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 @@ -54,12 +163,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..4ba6eb5a5d94 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: event, + 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 @@ -13204,6 +13207,12 @@ extension GhosttyNSView: NSTextInputClient { return } + if keyTextAccumulator != nil, + shouldBufferBopomofoInsertedPreedit(chars) { + insertBopomofoPreeditText(chars, replacementRange: replacementRange) + return + } + // Clear marked text since we're inserting unmarkText() @@ -13254,6 +13263,32 @@ extension GhosttyNSView: NSTextInputClient { preserveLiteralEscape: !isExternalCommittedText ) } + + private func insertBopomofoPreeditText(_ chars: String, replacementRange: NSRange) { + 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( + NSRange(location: insertionLocation, length: 0), + markedLength: markedText.length + ) + return + } + + markedText.append(NSAttributedString(string: chars)) + markedSelectedRange = normalizedMarkedSelectionRange( + NSRange(location: markedText.length, length: 0), + 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 982956c1a7ec..86493cf8771d 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 @@ -15,8 +16,6 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { let surfaceView: GhosttyNSView } - deinit {} - private func makeHostedTerminalWindow() throws -> HostedTerminalWindow { _ = NSApplication.shared @@ -70,6 +69,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 +213,220 @@ 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 + } + + AppDelegate.installWindowResponderSwizzlesForTesting() + 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) + try 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 + ) + window.sendEvent(event) + XCTAssertEqual( + surfaceView.selectedRange(), + probe.selectionAfter, + "Korean 2-Set arrow handling should apply the IME marked-selection update" + ) + } + } + + XCTAssertEqual( + forwardedPressKeyCodes, + probes.map { UInt32($0.keyCode) }, + "Korean 2-Set Left/Right after Hangul composition should reach the terminal cursor 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, + inputSourceId: "com.apple.inputmethod.TCIM.Zhuyin" + ), + "Zhuyin Down belongs to the IME candidate menu and should not also move the terminal cursor" + ) + } + + func testDoesNotSuppressIdleZhuyinNavigationKeyWithoutMarkedText() throws { + let view = GhosttyNSView(frame: .zero) + let probes: [(text: String, keyCode: UInt16)] = [ + ("\u{F701}", UInt16(kVK_DownArrow)), + (" ", UInt16(kVK_Space)), + ] + + for probe in probes { + let event = try keyEvent( + text: probe.text, + keyCode: probe.keyCode, + windowNumber: 0 + ) + + 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() { + 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)) + 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.selectedRange(), NSRange(location: 3, length: 0)) + XCTAssertEqual( + view.keyTextAccumulatorForTesting, + [], + "Raw Zhuyin components must not be committed to the terminal before candidate selection" + ) + } + + 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 + 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)