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 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue