Repository navigation
Accessibility: AXVisibleCharacterRange returns wrong data; AXRangeForPosition, AXBoundsForRange missed #9930
Issue DescriptionGhostty’s accessibility implementation is missing attributes needed by assistive SW and third-party tools to query text at the cursor position, for example and more problematic, implement some API in wrong way. I found this during debugging of my translation app when clicked on Ghostty app, which works across the system and has text access and fast tracking. Usually, it takes 10–20 ms in other apps, like Chrome or others, but here it took 3 seconds. I used The main issue is that it returns the whole terminal blob in both cases: NOTE: For debug I suggest to use macOS buildin tool - https://developer.apple.com/documentation/accessibility/accessibility-inspector Similar ticketComparison Behavior with Terminal.app
Terminal.app with 3M chars buffer correctly returns only visible viewport (~12K chars). Expected Behavior
Actual Behavior
Reproduction Steps
check_accessibility.swift#!/usr/bin/env swift
//
// check_accessibility.swift
// Diagnostic tool for testing terminal accessibility attributes on macOS
//
// Usage:
// 1. chmod +x check_accessibility.swift
// 2. Run: sleep 3 && ./check_accessibility.swift
// 3. Position your cursor over the terminal text within 3 seconds
//
import AppKit
import ApplicationServices
import Foundation
// MARK: - Accessibility Helpers
struct DiagnosticResult {
let attribute: String
let supported: Bool
let value: String
let timing: Double
let notes: String
}
class AccessibilityDiagnostic {
private let element: AXUIElement
private var results: [DiagnosticResult] = []
init(element: AXUIElement) {
self.element = element
}
func getStringAttribute(_ attribute: String) -> (String?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var value: CFTypeRef?
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let cfString = value as? String {
return (cfString, elapsed)
}
return (nil, elapsed)
}
func getNumberAttribute(_ attribute: String) -> (Int?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var value: CFTypeRef?
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let number = value as? NSNumber {
return (number.intValue, elapsed)
}
return (nil, elapsed)
}
func getRangeAttribute(_ attribute: String) -> (CFRange?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var value: CFTypeRef?
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let axValue = value {
var range = CFRange(location: 0, length: 0)
if AXValueGetValue(axValue as! AXValue, .cfRange, &range) {
return (range, elapsed)
}
}
return (nil, elapsed)
}
func getRangeForPosition(_ point: CGPoint) -> (CFRange?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var cgPoint = point
guard let pointValue = AXValueCreate(.cgPoint, &cgPoint) else {
return (nil, 0)
}
var result: CFTypeRef?
let error = AXUIElementCopyParameterizedAttributeValue(
element,
kAXRangeForPositionParameterizedAttribute as CFString,
pointValue,
&result
)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let axValue = result {
var range = CFRange(location: 0, length: 0)
if AXValueGetValue(axValue as! AXValue, .cfRange, &range) {
return (range, elapsed)
}
}
return (nil, elapsed)
}
func getStringForRange(_ range: CFRange) -> (String?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var cfRange = range
guard let rangeValue = AXValueCreate(.cfRange, &cfRange) else {
return (nil, 0)
}
var result: CFTypeRef?
let error = AXUIElementCopyParameterizedAttributeValue(
element,
kAXStringForRangeParameterizedAttribute as CFString,
rangeValue,
&result
)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let str = result as? String {
return (str, elapsed)
}
return (nil, elapsed)
}
func getBoundsForRange(_ range: CFRange) -> (CGRect?, Double) {
let start = CFAbsoluteTimeGetCurrent()
var cfRange = range
guard let rangeValue = AXValueCreate(.cfRange, &cfRange) else {
return (nil, 0)
}
var result: CFTypeRef?
let error = AXUIElementCopyParameterizedAttributeValue(
element,
kAXBoundsForRangeParameterizedAttribute as CFString,
rangeValue,
&result
)
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
if error == .success, let axValue = result {
var rect = CGRect.zero
if AXValueGetValue(axValue as! AXValue, .cgRect, &rect) {
return (rect, elapsed)
}
}
return (nil, elapsed)
}
func runDiagnostics(cursorPosition: CGPoint) -> [DiagnosticResult] {
results = []
// 1. AXRole
let (role, roleTime) = getStringAttribute(kAXRoleAttribute as String)
results.append(DiagnosticResult(
attribute: "AXRole",
supported: role != nil,
value: role ?? "N/A",
timing: roleTime,
notes: "Element type"
))
// 2. AXValue (full text)
let (value, valueTime) = getStringAttribute(kAXValueAttribute as String)
let valueLen = value?.count ?? 0
results.append(DiagnosticResult(
attribute: "AXValue",
supported: value != nil,
value: "\(valueLen) chars",
timing: valueTime,
notes: "Full text content"
))
// 3. AXNumberOfCharacters
let (charCount, charCountTime) = getNumberAttribute(kAXNumberOfCharactersAttribute as String)
results.append(DiagnosticResult(
attribute: "AXNumberOfCharacters",
supported: charCount != nil,
value: charCount.map { String($0) } ?? "N/A",
timing: charCountTime,
notes: "Total character count"
))
// 4. AXVisibleCharacterRange
let (visibleRange, visibleRangeTime) = getRangeAttribute(kAXVisibleCharacterRangeAttribute as String)
var visibleRangeNotes = "Visible text range"
if let vr = visibleRange, let total = charCount {
if vr.length == total {
visibleRangeNotes = "BUG: Returns full buffer, not viewport"
}
}
results.append(DiagnosticResult(
attribute: "AXVisibleCharacterRange",
supported: visibleRange != nil,
value: visibleRange.map { "loc=\($0.location) len=\($0.length)" } ?? "N/A",
timing: visibleRangeTime,
notes: visibleRangeNotes
))
// 5. AXRangeForPosition (CRITICAL)
let (rangeForPos, rangeForPosTime) = getRangeForPosition(cursorPosition)
var rangeNotes = "Char at cursor position"
var rangeSupported = rangeForPos != nil
if let r = rangeForPos {
if r.location == 0 && r.length == 0 {
rangeNotes = "BUG: Returns (0,0)"
rangeSupported = false
}
}
results.append(DiagnosticResult(
attribute: "AXRangeForPosition",
supported: rangeSupported,
value: rangeForPos.map { "loc=\($0.location) len=\($0.length)" } ?? "N/A",
timing: rangeForPosTime,
notes: rangeNotes
))
// 6. AXStringForRange
if let vr = visibleRange {
let testRange = CFRange(location: vr.location, length: min(500, vr.length))
let (str, strTime) = getStringForRange(testRange)
let preview = str.map { s -> String in
let trimmed = String(s.prefix(200)).replacingOccurrences(of: "\n", with: "\\n")
return "\"\(trimmed)\"" + (s.count > 200 ? "..." : "")
}
results.append(DiagnosticResult(
attribute: "AXStringForRange",
supported: str != nil,
value: preview ?? "N/A",
timing: strTime,
notes: "Extract text for range (first 500 chars)"
))
}
// 7. AXBoundsForRange
let testBoundsRange: CFRange
if let r = rangeForPos, r.length > 0 {
testBoundsRange = r
} else if let vr = visibleRange {
testBoundsRange = CFRange(location: vr.location, length: 1)
} else {
testBoundsRange = CFRange(location: 0, length: 1)
}
let (bounds, boundsTime) = getBoundsForRange(testBoundsRange)
results.append(DiagnosticResult(
attribute: "AXBoundsForRange",
supported: bounds != nil,
value: bounds.map { "(\(Int($0.origin.x)),\(Int($0.origin.y))) \(Int($0.width))x\(Int($0.height))" } ?? "N/A",
timing: boundsTime,
notes: "Screen bounds for range"
))
return results
}
func printResults(appName: String, cursorPosition: CGPoint) {
print("")
print("=== Accessibility Diagnostic Report ===")
print("")
print("Application: \(appName)")
print("Cursor: (\(Int(cursorPosition.x)), \(Int(cursorPosition.y)))")
print("Date: \(ISO8601DateFormatter().string(from: Date()))")
print("")
print("--- Attributes ---")
print("")
for result in results {
let status = result.supported ? "[OK]" : "[FAIL]"
let timing = String(format: "%.2fms", result.timing)
print("\(status) \(result.attribute)")
print(" Value: \(result.value)")
print(" Time: \(timing)")
if result.notes.contains("BUG") {
print(" Note: \(result.notes)")
}
print("")
}
print("--- Summary ---")
print("")
let rangeForPosResult = results.first { $0.attribute == "AXRangeForPosition" }
let visibleRangeResult = results.first { $0.attribute == "AXVisibleCharacterRange" }
if let rfp = rangeForPosResult {
if rfp.supported {
print("[OK] AXRangeForPosition works - fast lookup possible")
} else {
print("[FAIL] AXRangeForPosition not working - must use BoundsSearch fallback")
}
}
if let vr = visibleRangeResult {
if vr.notes.contains("BUG") {
print("[WARN] AXVisibleCharacterRange returns full buffer instead of viewport")
} else if vr.supported {
print("[OK] AXVisibleCharacterRange works correctly")
} else {
print("[FAIL] AXVisibleCharacterRange not supported")
}
}
print("")
}
}
// MARK: - Main
func main() {
// Check accessibility permission
guard AXIsProcessTrusted() else {
print("[ERROR] Accessibility permission not granted!")
print("Grant permission in: System Settings > Privacy & Security > Accessibility")
exit(1)
}
// Get cursor position
let mouseLocation = NSEvent.mouseLocation
guard let screen = NSScreen.main else {
print("[ERROR] Cannot get screen info")
exit(1)
}
let screenHeight = screen.frame.height
let cursorPosition = CGPoint(x: mouseLocation.x, y: screenHeight - mouseLocation.y)
print("Cursor at: (\(Int(cursorPosition.x)), \(Int(cursorPosition.y)))")
print("Querying accessibility...")
// Get element at cursor position
let systemWide = AXUIElementCreateSystemWide()
var element: AXUIElement?
let error = AXUIElementCopyElementAtPosition(
systemWide,
Float(cursorPosition.x),
Float(cursorPosition.y),
&element
)
guard error == .success, let el = element else {
print("[ERROR] No accessible element at cursor (error: \(error.rawValue))")
exit(1)
}
// Get app name
var pid: pid_t = 0
AXUIElementGetPid(el, &pid)
let appName = NSRunningApplication(processIdentifier: pid)?.localizedName ?? "Unknown"
// Run diagnostics
let diagnostic = AccessibilityDiagnostic(element: el)
_ = diagnostic.runDiagnostics(cursorPosition: cursorPosition)
diagnostic.printResults(appName: appName, cursorPosition: cursorPosition)
}
main()Ghostty LogsNo response Ghostty VersionOS Version InformationmacOS 26.1 (25B78) (Linux only) Display ServerNone (Linux only) Desktop Environment/Window ManagerNo response Minimal Ghostty ConfigurationN/AAdditional Relevant ConfigurationN/A I acknowledge that:
|
Wow, this is an excellently written bug report. I've converted it directly to an issue. Thank you so much. #9932