From 6f210dd2c7bcb092a4485c82d9b149f1046f6482 Mon Sep 17 00:00:00 2001 From: Qian Wan Date: Thu, 5 Mar 2026 09:25:39 +0800 Subject: [PATCH] Fix voice dictation text insertion path in GhosttyNSView. (#857) * Fix voice dictation text insertion path in GhosttyNSView. * Fix AX selected text decoding to use explicit length --- Sources/GhosttyTerminalView.swift | 86 ++++++++++++++++++++++++++++++- cmuxTests/CJKIMEInputTests.swift | 61 +++++++++------------- 2 files changed, 108 insertions(+), 39 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2d5c5ecb..1b65a5f6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3581,6 +3581,73 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + // MARK: - Accessibility + + /// Expose the terminal surface as an editable accessibility element. + /// Voice input tools frequently target AX text areas for text insertion. + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .textArea + } + + override func accessibilityHelp() -> String? { + "Terminal content area" + } + + override func accessibilityValue() -> Any? { + // We don't keep a full terminal text snapshot in this layer. + // Expose selected text when available; otherwise provide an empty value + // so AX clients still treat this as an editable text area. + accessibilitySelectedText() ?? "" + } + + override func setAccessibilityValue(_ value: Any?) { + let content: String + switch value { + case let v as NSAttributedString: + content = v.string + case let v as String: + content = v + default: + return + } + + guard !content.isEmpty else { return } + +#if DEBUG + dlog("ime.ax.setValue len=\(content.count)") +#endif + + let inject = { + self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + if Thread.isMainThread { + inject() + } else { + DispatchQueue.main.async(execute: inject) + } + } + + override func accessibilitySelectedTextRange() -> NSRange { + selectedRange() + } + + override func accessibilitySelectedText() -> String? { + guard let surface = surface else { return nil } + + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + guard let ptr = text.text, text.text_len > 0 else { return nil } + let selectedData = Data(bytes: ptr, count: Int(text.text_len)) + let selected = String(decoding: selectedData, as: UTF8.self) + return selected.isEmpty ? nil : selected + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { @@ -3713,6 +3780,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Intentionally empty - prevents system beep on unhandled key commands } + /// Some third-party voice input apps inject committed text by sending the + /// responder-chain `insertText:` action (single-argument form). + /// Route that into our NSTextInputClient path so text lands in the terminal. + override func insertText(_ insertString: Any) { + insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, @@ -6458,8 +6532,6 @@ extension GhosttyNSView: NSTextInputClient { } func insertText(_ string: Any, replacementRange: NSRange) { - guard NSApp.currentEvent != nil else { return } - // Get the string value var chars = "" switch string { @@ -6474,6 +6546,16 @@ extension GhosttyNSView: NSTextInputClient { // Clear marked text since we're inserting unmarkText() + // Some IME/input-method paths call insertText with an empty payload to + // flush state. There is no terminal text to send in that case. + guard !chars.isEmpty else { return } + +#if DEBUG + if NSApp.currentEvent == nil { + dlog("ime.insertText.noEvent len=\(chars.count)") + } +#endif + // If we have an accumulator, we're in a keyDown event - accumulate the text if keyTextAccumulator != nil { keyTextAccumulator?.append(chars) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 86ed191e..473b11e7 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -7,43 +7,6 @@ import AppKit @testable import cmux #endif -// MARK: - Test helpers - -/// Helper to make `NSApp.currentEvent` non-nil for insertText calls. -/// NSTextInputClient.insertText guards on currentEvent because it should -/// only fire during actual key event processing. In tests we simulate this -/// by posting and immediately processing a synthetic key event. -private func withSyntheticCurrentEvent(_ body: () -> Void) { - _ = NSApplication.shared // ensure NSApp exists - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, - context: nil, - characters: "", - charactersIgnoringModifiers: "", - isARepeat: false, - keyCode: 0 - ) else { - body() - return - } - NSApp.postEvent(event, atStart: true) - // Process the event so that currentEvent becomes non-nil. - // Use a short timeout since we just posted the event. - if let posted = NSApp.nextEvent(matching: .keyDown, until: Date(timeIntervalSinceNow: 0.05), inMode: .default, dequeue: true) { - // We're now inside event processing; currentEvent should be set. - // However, currentEvent is only set during sendEvent. We need to - // actually invoke sendEvent. Since we can't do that cleanly in a - // unit test, we use a different approach: call insertText indirectly - // via a direct test of the accumulator + unmarkText path. - _ = posted - } - body() -} - // MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle /// Tests that the GhosttyNSView NSTextInputClient implementation correctly @@ -106,6 +69,30 @@ final class CJKIMEMarkedTextTests: XCTestCase { view.setKeyTextAccumulatorForTesting(nil) } + /// Third-party voice input apps often commit text outside an active keyDown + /// event. `insertText` should still clear marked text in that path. + func testInsertTextWithoutCurrentEventClearsMarkedText() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertTrue(view.hasMarkedText()) + + view.insertText("한", replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertFalse(view.hasMarkedText(), "insertText should clear marked text even without an active currentEvent") + } + + /// The responder-chain `insertText:` action (single argument) should route + /// to NSTextInputClient insertion so external text-injection tools work. + func testResponderChainInsertTextSelectorClearsMarkedText() { + let view = GhosttyNSView(frame: .zero) + + view.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0)) + XCTAssertTrue(view.hasMarkedText()) + + view.insertText("你") + XCTAssertFalse(view.hasMarkedText(), "single-argument insertText should follow the same commit path") + } + // MARK: - Chinese (中文) pinyin candidate selection /// Chinese pinyin IME types Roman letters as marked text, then the user