diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e4808ca3d2b1..9a94ee5ffba2 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; }; D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; }; D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */; }; + D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */; }; D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */; }; D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */; }; D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; }; @@ -334,6 +335,7 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; + D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; 1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */; }; D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; @@ -572,6 +574,7 @@ D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+SurfaceConfigurationReload.swift"; sourceTree = ""; }; + D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+IMEComposition.swift"; sourceTree = ""; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragOverlayRoutingPolicy.swift; sourceTree = ""; }; D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditor.swift; sourceTree = ""; }; @@ -767,6 +770,7 @@ F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; + D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyCommandShiftForwardingTests.swift; sourceTree = ""; }; D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraditionalChineseIMENumpadRegressionTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; @@ -1050,6 +1054,7 @@ D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */, D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */, D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */, + D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */, D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */, A5001531 /* TerminalWindowPortal.swift */, D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */, @@ -1202,6 +1207,7 @@ children = ( F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */, 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */, D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, @@ -1628,6 +1634,7 @@ D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */, D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */, + D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, D0B10014A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, @@ -1821,6 +1828,7 @@ files = ( F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, 1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */, D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, diff --git a/Sources/GhosttyNSView+IMEComposition.swift b/Sources/GhosttyNSView+IMEComposition.swift new file mode 100644 index 000000000000..dc51ba0abfe9 --- /dev/null +++ b/Sources/GhosttyNSView+IMEComposition.swift @@ -0,0 +1,66 @@ +import AppKit + +extension GhosttyNSView { + /// Clamps AppKit's marked-text selection into the active preedit buffer. + func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange { + guard markedLength > 0 else { + return NSRange(location: NSNotFound, length: 0) + } + guard range.location != NSNotFound else { + return NSRange(location: markedLength, length: 0) + } + + let clampedLocation = min(max(range.location, 0), markedLength) + let clampedLength = min(max(range.length, 0), markedLength - clampedLocation) + return NSRange(location: clampedLocation, length: clampedLength) + } + + /// Clamps an AppKit substring query so it can be served from marked text. + func clampedMarkedTextRange(_ range: NSRange, markedLength: Int) -> NSRange? { + guard range.length > 0, range.location != NSNotFound else { return nil } + guard markedLength > 0 else { return nil } + + let location = min(max(range.location, 0), markedLength) + let maxLength = markedLength - location + guard maxLength > 0 else { return nil } + + let length = min(max(range.length, 0), maxLength) + guard length > 0 else { return nil } + return NSRange(location: location, length: length) + } + + /// Returns true when AppKit consumed the key by changing IME composition state. + func shouldSuppressGhosttyKeyForwardingAfterIMEHandling( + before: (text: String, selection: NSRange), + after: (text: String, selection: NSRange), + accumulatedText: [String] + ) -> Bool { + guard accumulatedText.isEmpty else { return false } + + let hadMarkedTextBefore = !before.text.isEmpty + let hasMarkedTextAfter = !after.text.isEmpty + guard hadMarkedTextBefore || hasMarkedTextAfter else { return false } + + if before.text != after.text { + return true + } + + return before.selection != after.selection + } + +#if DEBUG + func shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: String, + markedSelectionBefore: NSRange, + markedTextAfter: String, + markedSelectionAfter: NSRange, + accumulatedText: [String] + ) -> Bool { + shouldSuppressGhosttyKeyForwardingAfterIMEHandling( + before: (markedTextBefore, markedSelectionBefore), + after: (markedTextAfter, markedSelectionAfter), + accumulatedText: accumulatedText + ) + } +#endif +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 79c26ba4200c..830ca2df70e4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7043,6 +7043,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // For NSTextInputClient - accumulates text during key events private(set) var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() + private var markedSelectedRange = NSRange(location: NSNotFound, length: 0) private var lastPerformKeyEvent: TimeInterval? private(set) var externalCommittedTextDepth = 0 var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator() @@ -7467,9 +7468,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyTextAccumulator = [] defer { keyTextAccumulator = nil } - // Track whether we had marked text (IME preedit) before this event, - // so we can detect when composition ends. 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). @@ -7518,6 +7518,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 #endif + let accumulatedText = keyTextAccumulator ?? [] + if shouldSuppressGhosttyKeyForwardingAfterIMEHandling( + before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText + ) { return } + // Build the key event var keyEvent = ghostty_input_key_s() keyEvent.action = action @@ -7536,7 +7541,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.composing = markedText.length > 0 || markedTextBefore // Use accumulated text from insertText (for IME), or compute text for key - let accumulatedText = keyTextAccumulator ?? [] var shouldRefreshAfterTextInput = false if !accumulatedText.isEmpty { // Accumulated text comes from insertText (IME composition result). @@ -12724,7 +12728,13 @@ extension GhosttyNSView: NSTextInputClient { } func selectedRange() -> NSRange { - readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) + if markedText.length > 0 { +#if DEBUG + assert(markedSelectedRange.location != NSNotFound, "markedSelectedRange must be valid") +#endif + return markedSelectedRange + } + return readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -12744,8 +12754,9 @@ extension GhosttyNSView: NSTextInputClient { case let v as String: markedText = NSMutableAttributedString(string: v) default: - break + return } + markedSelectedRange = normalizedMarkedSelectionRange(selectedRange, markedLength: markedText.length) // If we're not in a keyDown event, sync preedit immediately. // This can happen due to external events like changing keyboard layouts @@ -12770,6 +12781,7 @@ extension GhosttyNSView: NSTextInputClient { #endif if markedText.length > 0 { markedText.mutableString.setString("") + markedSelectedRange = NSRange(location: NSNotFound, length: 0) syncPreedit() invalidateTextInputCoordinates(selectionChanged: true) } @@ -12812,6 +12824,12 @@ extension GhosttyNSView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + if markedText.length > 0 { + guard let substringRange = clampedMarkedTextRange(range, markedLength: markedText.length) else { return nil } + actualRange?.pointee = substringRange + return markedText.attributedSubstring(from: substringRange) + } + guard range.length > 0, let snapshot = readSelectionSnapshot() else { return nil } actualRange?.pointee = snapshot.range diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 947861604e55..b3ccb265b59a 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -364,13 +364,7 @@ final class CJKIMEMarkedTextTests: XCTestCase { XCTAssertFalse(view.hasMarkedText()) } - // MARK: - selectedRange / validAttributesForMarkedText - - func testSelectedRangeReturnsEmptyRangeWithoutSelection() { - let view = GhosttyNSView(frame: .zero) - let range = view.selectedRange() - XCTAssertEqual(range, NSRange(location: 0, length: 0)) - } + // MARK: - validAttributesForMarkedText func testValidAttributesForMarkedTextReturnsEmpty() { let view = GhosttyNSView(frame: .zero) diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift new file mode 100644 index 000000000000..982956c1a7ec --- /dev/null +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -0,0 +1,251 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class CJKIMEMarkedSelectionTests: XCTestCase { + private struct HostedTerminalWindow { + let surface: TerminalSurface + let window: NSWindow + let surfaceView: GhosttyNSView + } + + deinit {} + + private func makeHostedTerminalWindow() throws -> HostedTerminalWindow { + _ = NSApplication.shared + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let contentView = try XCTUnwrap(window.contentView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + RunLoop.current.run(until: Date.now.addingTimeInterval(0.05)) + + let surfaceView = try XCTUnwrap(findGhosttyNSView(in: hostedView)) + return HostedTerminalWindow( + surface: surface, + window: window, + surfaceView: surfaceView + ) + } + + private func keyEvent(text: String, keyCode: UInt16, windowNumber: Int) throws -> NSEvent { + try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: text, + charactersIgnoringModifiers: text, + isARepeat: false, + keyCode: keyCode + )) + } + + func testSelectedRangeReturnsEmptyRangeWithoutSelectionOrMarkedText() { + let view = GhosttyNSView(frame: .zero) + let range = view.selectedRange() + XCTAssertEqual(range, NSRange(location: 0, length: 0)) + } + + func testSelectedRangeTracksMarkedTextSelection() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText( + "にほんご", + selectedRange: NSRange(location: 2, length: 1), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + XCTAssertEqual( + view.selectedRange(), + NSRange(location: 2, length: 1), + "selectedRange should mirror the IME caret/selection inside marked text" + ) + } + + func testSelectedRangeReturnsEmptyRangeAfterCompositionEnds() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText( + "東京", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + view.unmarkText() + + XCTAssertEqual(view.selectedRange(), NSRange(location: 0, length: 0)) + } + + func testAttributedSubstringReturnsMarkedTextSegment() { + let view = GhosttyNSView(frame: .zero) + view.setMarkedText( + "とうきょう", + selectedRange: NSRange(location: 3, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + var actualRange = NSRange(location: NSNotFound, length: 0) + let substring = view.attributedSubstring( + forProposedRange: NSRange(location: 2, length: 2), + actualRange: &actualRange + ) + + XCTAssertEqual(actualRange, NSRange(location: 2, length: 2)) + XCTAssertEqual(substring?.string, "きょ") + } + + func testTraditionalChineseZhuyinMarkedTextSelectionAndSubstring() { + let view = GhosttyNSView(frame: .zero) + view.setMarkedText( + "ㄓㄨ", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + XCTAssertEqual(view.selectedRange(), NSRange(location: 2, length: 0)) + + var actualRange = NSRange(location: NSNotFound, length: 0) + let substring = view.attributedSubstring( + forProposedRange: NSRange(location: 0, length: 2), + actualRange: &actualRange + ) + + XCTAssertEqual(actualRange, NSRange(location: 0, length: 2)) + XCTAssertEqual(substring?.string, "ㄓㄨ") + } + + func testSuppressesTerminalForwardingWhenZhuyinStartsMarkedText() { + let view = GhosttyNSView(frame: .zero) + + XCTAssertTrue( + view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: "", + markedSelectionBefore: NSRange(location: NSNotFound, length: 0), + markedTextAfter: "ㄓ", + markedSelectionAfter: NSRange(location: 1, length: 0), + accumulatedText: [] + ) + ) + } + + func testKeyDownDoesNotForwardWhenZhuyinStartsMarkedText() 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 } + candidateView.setMarkedText( + "ㄓ", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + return true + } + + var forwardedPressCount = 0 + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } + forwardedPressCount += 1 + } + + let event = try keyEvent(text: "5", keyCode: 23, windowNumber: window.windowNumber) + + window.makeFirstResponder(surfaceView) + withExtendedLifetime(terminalSurface) { + surfaceView.keyDown(with: event) + } + + XCTAssertTrue(surfaceView.hasMarkedText(), "Zhuyin keyDown should start marked text") + XCTAssertEqual( + forwardedPressCount, + 0, + "AppKit-consumed Zhuyin marked-text changes must not forward a duplicate Ghostty key" + ) + } + + func testSuppressesTerminalForwardingWhenZhuyinMarkedTextChanges() { + let view = GhosttyNSView(frame: .zero) + + XCTAssertTrue( + view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: "ㄓ", + markedSelectionBefore: NSRange(location: 1, length: 0), + markedTextAfter: "ㄓㄨ", + markedSelectionAfter: NSRange(location: 2, length: 0), + accumulatedText: [] + ) + ) + } + + func testDoesNotSuppressCommittedIMEInsertText() { + let view = GhosttyNSView(frame: .zero) + + XCTAssertFalse( + view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: "ㄓㄨ", + markedSelectionBefore: NSRange(location: 2, length: 0), + markedTextAfter: "", + markedSelectionAfter: NSRange(location: NSNotFound, length: 0), + accumulatedText: ["注"] + ) + ) + } + + func testDoesNotSuppressNormalTerminalKeyWhenIMEDidNothing() { + let view = GhosttyNSView(frame: .zero) + + XCTAssertFalse( + view.shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting( + markedTextBefore: "", + markedSelectionBefore: NSRange(location: NSNotFound, length: 0), + markedTextAfter: "", + markedSelectionAfter: NSRange(location: NSNotFound, length: 0), + accumulatedText: [] + ) + ) + } +}