Fix macOS dictation NSTextInputClient conformance (#1410)

* Add dictation text input regression tests

* Fix dictation text input client conformance

* Fix dictation caret rect anchoring

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-13 18:14:02 -07:00 committed by GitHub
parent f90bcbc862
commit 98f5553335
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 12 deletions

View file

@ -3804,6 +3804,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
source: "surface.viewDidMoveToWindow"
)
applyWindowBackgroundIfActive()
invalidateTextInputCoordinates()
}
override func viewDidChangeEffectiveAppearance() {
@ -3835,11 +3836,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
CATransaction.commit()
}
updateSurfaceSize()
invalidateTextInputCoordinates()
}
override func layout() {
super.layout()
updateSurfaceSize()
invalidateTextInputCoordinates()
}
override var isOpaque: Bool { false }
@ -4388,16 +4391,45 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func accessibilitySelectedText() -> String? {
guard let surface = surface else { return nil }
guard let snapshot = readSelectionSnapshot() else { return nil }
return snapshot.string.isEmpty ? nil : snapshot.string
}
private func readSelectionSnapshot() -> SelectionSnapshot? {
guard let 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
let selected: String
if let ptr = text.text, text.text_len > 0 {
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
selected = String(decoding: selectedData, as: UTF8.self)
} else {
selected = ""
}
return SelectionSnapshot(
range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)),
string: selected,
topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y)
)
}
private func visibleDocumentRectInScreenCoordinates() -> NSRect {
let localRect = visibleRect
let windowRect = convert(localRect, to: nil)
guard let window else { return windowRect }
return window.convertToScreen(windowRect)
}
private func invalidateTextInputCoordinates(selectionChanged: Bool = false) {
guard let inputContext else { return }
inputContext.invalidateCharacterCoordinates()
if #available(macOS 15.4, *), selectionChanged {
inputContext.textInputClientDidUpdateSelection()
}
}
override var acceptsFirstResponder: Bool { true }
@ -4492,6 +4524,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var keyTextAccumulator: [String]? = nil
private var markedText = NSMutableAttributedString()
private var lastPerformKeyEvent: TimeInterval?
private struct SelectionSnapshot {
let range: NSRange
let string: String
let topLeft: CGPoint
}
#if DEBUG
// Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior.
@ -8056,7 +8093,7 @@ extension GhosttyNSView: NSTextInputClient {
}
func selectedRange() -> NSRange {
return NSRange(location: NSNotFound, length: 0)
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@ -8084,6 +8121,7 @@ extension GhosttyNSView: NSTextInputClient {
// while composing.
if keyTextAccumulator == nil {
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
}
@ -8102,6 +8140,7 @@ extension GhosttyNSView: NSTextInputClient {
if markedText.length > 0 {
markedText.mutableString.setString("")
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
}
@ -8142,11 +8181,14 @@ extension GhosttyNSView: NSTextInputClient {
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil
guard range.length > 0,
let snapshot = readSelectionSnapshot() else { return nil }
actualRange?.pointee = snapshot.range
return NSAttributedString(string: snapshot.string)
}
func characterIndex(for point: NSPoint) -> Int {
return 0
return selectedRange().location
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
@ -8160,7 +8202,12 @@ extension GhosttyNSView: NSTextInputClient {
var w: Double = cellSize.width
var h: Double = cellSize.height
#if DEBUG
if let override = imePointOverrideForTesting {
if range.length > 0,
range != selectedRange(),
let snapshot = readSelectionSnapshot() {
x = snapshot.topLeft.x - 2
y = snapshot.topLeft.y + 2
} else if let override = imePointOverrideForTesting {
x = override.x
y = override.y
w = override.width
@ -8169,11 +8216,21 @@ extension GhosttyNSView: NSTextInputClient {
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
}
#else
if let surface = surface {
if range.length > 0,
range != selectedRange(),
let snapshot = readSelectionSnapshot() {
x = snapshot.topLeft.x - 2
y = snapshot.topLeft.y + 2
} else if let surface = surface {
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
}
#endif
if range.length == 0, w > 0 {
// Dictation expects a caret rect for insertion points rather than a box.
w = 0
}
// Ghostty coordinates are top-left origin; AppKit expects bottom-left.
let viewRect = NSRect(
x: x,
@ -8185,6 +8242,30 @@ extension GhosttyNSView: NSTextInputClient {
return window.convertToScreen(winRect)
}
func attributedString() -> NSAttributedString {
if markedText.length > 0 {
return NSAttributedString(attributedString: markedText)
}
if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty {
return NSAttributedString(string: snapshot.string)
}
return NSAttributedString(string: "")
}
func windowLevel() -> Int {
Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue)
}
@available(macOS 14.0, *)
var unionRectInVisibleSelectedRange: NSRect {
firstRect(forCharacterRange: selectedRange(), actualRange: nil)
}
@available(macOS 14.0, *)
var documentVisibleRect: NSRect {
visibleDocumentRectInScreenCoordinates()
}
func insertText(_ string: Any, replacementRange: NSRange) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()

View file

@ -232,10 +232,10 @@ final class CJKIMEMarkedTextTests: XCTestCase {
// MARK: - selectedRange / validAttributesForMarkedText
func testSelectedRangeReturnsNotFound() {
func testSelectedRangeReturnsEmptyRangeWithoutSelection() {
let view = GhosttyNSView(frame: .zero)
let range = view.selectedRange()
XCTAssertEqual(range.location, NSNotFound)
XCTAssertEqual(range, NSRange(location: 0, length: 0))
}
func testValidAttributesForMarkedTextReturnsEmpty() {
@ -694,6 +694,68 @@ final class CJKIMEFirstRectTests: XCTestCase {
XCTAssertEqual(rect.width, 36, accuracy: 0.001)
XCTAssertEqual(rect.height, 18, accuracy: 0.001)
}
func testFirstRectUsesZeroWidthForInsertionPointWithoutOffsettingCaretAnchor() {
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
let view = GhosttyNSView(frame: frame)
view.cellSize = CGSize(width: 9, height: 18)
view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 24)
let window = NSWindow(
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
view.clearIMEPointForTesting()
window.orderOut(nil)
}
let rect = view.firstRect(forCharacterRange: NSRange(location: 5, length: 0), actualRange: nil)
let expectedViewRect = NSRect(x: 80, y: frame.height - 120, width: 0, height: 24)
let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil))
XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, 0, accuracy: 0.001)
XCTAssertEqual(rect.height, 24, accuracy: 0.001)
}
func testDocumentVisibleRectUsesScreenCoordinates() {
guard #available(macOS 14.0, *) else { return }
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
let view = GhosttyNSView(frame: frame)
let window = NSWindow(
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
window.orderOut(nil)
}
let expected = window.convertToScreen(view.convert(view.visibleRect, to: nil))
let rect = view.documentVisibleRect
XCTAssertEqual(rect.origin.x, expected.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, expected.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, expected.width, accuracy: 0.001)
XCTAssertEqual(rect.height, expected.height, accuracy: 0.001)
}
}
// MARK: - Key text accumulator during CJK IME composition