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
This commit is contained in:
Qian Wan 2026-03-05 09:25:39 +08:00 committed by GitHub
parent 80bbfdf206
commit 6f210dd2c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 108 additions and 39 deletions

View file

@ -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 var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool { override func becomeFirstResponder() -> Bool {
@ -3713,6 +3780,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Intentionally empty - prevents system beep on unhandled key commands // 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 { override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown else { return false } guard event.type == .keyDown else { return false }
guard let fr = window?.firstResponder as? NSView, guard let fr = window?.firstResponder as? NSView,
@ -6458,8 +6532,6 @@ extension GhosttyNSView: NSTextInputClient {
} }
func insertText(_ string: Any, replacementRange: NSRange) { func insertText(_ string: Any, replacementRange: NSRange) {
guard NSApp.currentEvent != nil else { return }
// Get the string value // Get the string value
var chars = "" var chars = ""
switch string { switch string {
@ -6474,6 +6546,16 @@ extension GhosttyNSView: NSTextInputClient {
// Clear marked text since we're inserting // Clear marked text since we're inserting
unmarkText() 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 we have an accumulator, we're in a keyDown event - accumulate the text
if keyTextAccumulator != nil { if keyTextAccumulator != nil {
keyTextAccumulator?.append(chars) keyTextAccumulator?.append(chars)

View file

@ -7,43 +7,6 @@ import AppKit
@testable import cmux @testable import cmux
#endif #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 // MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle
/// Tests that the GhosttyNSView NSTextInputClient implementation correctly /// Tests that the GhosttyNSView NSTextInputClient implementation correctly
@ -106,6 +69,30 @@ final class CJKIMEMarkedTextTests: XCTestCase {
view.setKeyTextAccumulatorForTesting(nil) 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 // MARK: - Chinese () pinyin candidate selection
/// Chinese pinyin IME types Roman letters as marked text, then the user /// Chinese pinyin IME types Roman letters as marked text, then the user