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:
parent
f90bcbc862
commit
98f5553335
2 changed files with 155 additions and 12 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue