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:
parent
80bbfdf206
commit
6f210dd2c7
2 changed files with 108 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue