Add macOS accessibility support for terminal content

- Add ghostty_surface_viewport_text C API to extract visible terminal text
- Implement NSAccessibility protocol in SurfaceView_AppKit.swift
- Add throttled accessibility notifications on content changes
- Document implementation in ACCESSIBILITY_IMPLEMENTATION.md

This allows VoiceOver and other assistive technologies to read terminal content.
This commit is contained in:
Peter Steinberger
2025-06-06 01:43:28 +01:00
parent 08101b0bc5
commit 7870ab693a
4 changed files with 309 additions and 0 deletions

View File

@ -0,0 +1,82 @@
# Ghostty macOS Accessibility Implementation
This document describes the accessibility support added to Ghostty for macOS.
## Overview
The implementation adds basic accessibility support to the Ghostty terminal emulator, allowing assistive technologies like VoiceOver to read the terminal content. This is achieved by:
1. Adding a C API function to extract the visible terminal viewport text
2. Implementing NSAccessibility protocol methods in the macOS SurfaceView
## Changes Made
### 1. C API Addition (src/apprt/embedded.zig)
Added `ghostty_surface_viewport_text` function that:
- Extracts the visible text content from the terminal viewport
- Handles proper UTF-8 encoding
- Trims trailing spaces from lines
- Returns properly formatted text with newlines between rows
### 2. Header File Update (include/ghostty.h)
Added the function declaration:
```c
uintptr_t ghostty_surface_viewport_text(ghostty_surface_t, char*, uintptr_t);
```
### 3. macOS Accessibility Implementation (macos/Sources/Ghostty/SurfaceView_AppKit.swift)
Implemented the following NSAccessibility protocol methods:
- `isAccessibilityElement()` - Returns true to indicate this is an accessible element
- `accessibilityRole()` - Returns `.textArea` role
- `accessibilityValue()` - Returns the current viewport text using the C API
- `accessibilityLabel()` - Returns "Terminal" as the label
- `isAccessibilityFocused()` - Returns the current focus state
- `accessibilitySelectedText()` - Returns selected text if any
- `accessibilitySelectedTextRange()` - Returns the range of selected text
- `accessibilityNumberOfCharacters()` - Returns the character count
- `accessibilityString(for:)` - Returns text for a specific range
- `accessibilityLine(for:)` - Returns line number for a character index
- `accessibilityRange(forLine:)` - Returns the range for a specific line
- `accessibilityPerformPress()` - Focuses the terminal when activated
### 4. Automatic Updates
Added throttled accessibility notifications when the terminal content changes:
- Updates are throttled to maximum 2 times per second to avoid overwhelming the accessibility system
- Notifications are sent when the terminal layer is redrawn
## Usage
With these changes, macOS accessibility tools can now:
1. Read the terminal content using VoiceOver
2. Navigate through the text line by line
3. Access selected text
4. Be notified when content changes
## Building
To build Ghostty with these changes:
1. Install Zig (required for building the core library)
2. Run `zig build` to build the XCFramework
3. Open `macos/Ghostty.xcodeproj` in Xcode
4. Build and run the project
## Testing
To test the accessibility features:
1. Enable VoiceOver (Cmd+F5)
2. Navigate to the Ghostty terminal window
3. Use VoiceOver commands to read the terminal content
4. The Accessibility Inspector should now show the terminal text content
## Future Improvements
Potential enhancements could include:
- Support for cursor position tracking
- More granular text change notifications
- Support for reading specific regions (like prompts vs output)
- Integration with terminal semantic markers

View File

@ -830,6 +830,7 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
bool);
bool ghostty_surface_has_selection(ghostty_surface_t);
uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t);
uintptr_t ghostty_surface_viewport_text(ghostty_surface_t, char*, uintptr_t);
#ifdef __APPLE__
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);

View File

@ -662,6 +662,23 @@ extension Ghostty {
override func updateLayer() {
guard let surface = self.surface else { return }
ghostty_surface_draw(surface);
// Notify accessibility of potential content changes
// We do this on a throttled basis to avoid overwhelming the accessibility system
throttledAccessibilityUpdate()
}
private var lastAccessibilityUpdateTime: TimeInterval = 0
private let accessibilityUpdateInterval: TimeInterval = 0.5 // Update at most twice per second
private func throttledAccessibilityUpdate() {
let currentTime = CACurrentMediaTime()
if currentTime - lastAccessibilityUpdateTime >= accessibilityUpdateInterval {
lastAccessibilityUpdateTime = currentTime
DispatchQueue.main.async { [weak self] in
self?.notifyAccessibilityContentChanged()
}
}
}
override func mouseDown(with event: NSEvent) {
@ -1745,6 +1762,139 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
}
}
// MARK: - NSAccessibility
extension Ghostty.SurfaceView {
override func isAccessibilityElement() -> Bool {
return true
}
override func accessibilityRole() -> NSAccessibility.Role? {
return .textArea
}
override func accessibilityRoleDescription() -> String? {
return NSAccessibility.Role.textArea.description(with: nil)
}
override func accessibilityValue() -> Any? {
// Get the viewport text from the terminal
guard let surface = self.surface else { return "" }
// Allocate a buffer for the text (1MB should be enough for most terminals)
let bufferSize = 1_048_576
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
let length = ghostty_surface_viewport_text(surface, buffer, bufferSize)
guard length > 0 else { return "" }
return String(cString: buffer)
}
override func accessibilityLabel() -> String? {
return "Terminal"
}
override func isAccessibilityFocused() -> Bool {
return self.focused
}
override func accessibilitySelectedText() -> String? {
guard let surface = self.surface else { return nil }
guard ghostty_surface_has_selection(surface) else { return nil }
// Get the selection text
let bufferSize = 1_000_000
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
let length = ghostty_surface_selection(surface, buffer, bufferSize)
guard length > 0 else { return nil }
return String(bytesNoCopy: buffer, length: Int(length), encoding: .utf8, freeWhenDone: false)
}
override func accessibilitySelectedTextRange() -> NSRange {
guard let surface = self.surface else { return NSRange() }
var sel: ghostty_selection_s = ghostty_selection_s()
guard ghostty_surface_selection_info(surface, &sel) else { return NSRange() }
return NSRange(location: Int(sel.offset_start), length: Int(sel.offset_len))
}
override func accessibilityNumberOfCharacters() -> Int {
guard let text = self.accessibilityValue() as? String else { return 0 }
return text.count
}
override func accessibilityString(for range: NSRange) -> String? {
guard let text = self.accessibilityValue() as? String else { return nil }
guard let stringRange = Range(range, in: text) else { return nil }
return String(text[stringRange])
}
override func accessibilityLine(for index: Int) -> Int {
guard let text = self.accessibilityValue() as? String else { return 0 }
var lineNumber = 0
var currentIndex = 0
for char in text {
if currentIndex >= index { break }
if char == "\n" { lineNumber += 1 }
currentIndex += 1
}
return lineNumber
}
override func accessibilityRange(forLine line: Int) -> NSRange {
guard let text = self.accessibilityValue() as? String else { return NSRange() }
var currentLine = 0
var lineStart = 0
var lineEnd = 0
var index = 0
for char in text {
if currentLine == line && lineStart == 0 {
lineStart = index
}
if char == "\n" {
if currentLine == line {
lineEnd = index
break
}
currentLine += 1
}
index += 1
}
// If we reached the end without finding a newline
if currentLine == line && lineEnd == 0 {
lineEnd = text.count
}
return NSRange(location: lineStart, length: lineEnd - lineStart)
}
// Support for accessibility actions
override func accessibilityPerformPress() -> Bool {
// Focus the terminal when pressed
self.window?.makeFirstResponder(self)
return true
}
// Notify accessibility when content changes
func notifyAccessibilityContentChanged() {
NSAccessibility.post(element: self, notification: .valueChanged)
}
}
// MARK: NSDraggingDestination
extension Ghostty.SurfaceView {

View File

@ -1383,6 +1383,82 @@ pub const CAPI = struct {
return selection.len;
}
/// Get the visible text content of the terminal viewport. Returns the
/// number of bytes written. If the buffer is too small, returns 0.
export fn ghostty_surface_viewport_text(surface: *Surface, buf: [*]u8, cap: usize) usize {
const t = surface.core_surface.terminal;
const screen = &t.screen;
// Allocate a temporary buffer to build the string
var temp_buf = std.ArrayList(u8).init(global.alloc);
defer temp_buf.deinit();
// Iterate through visible rows in the viewport
var row_idx: usize = 0;
while (row_idx < t.rows) : (row_idx += 1) {
const pin = screen.pages.pin(.{ .viewport = .{
.y = @intCast(row_idx),
.x = 0,
} }) catch continue;
defer screen.pages.unpin(pin);
const row = pin.rowAndCell().row;
// Track trailing spaces to trim them
var last_non_space: usize = 0;
var line_start = temp_buf.items.len;
// Convert row to text
var col: usize = 0;
while (col < t.cols) : (col += 1) {
const cell = row.cells.get(col);
if (cell.content.codepoint > 0) {
// Get the text for this cell
var char_buf: [4]u8 = undefined;
const len = std.unicode.utf8Encode(cell.content.codepoint, &char_buf) catch continue;
temp_buf.appendSlice(char_buf[0..len]) catch return 0;
// Track non-space position
if (cell.content.codepoint != ' ') {
last_non_space = temp_buf.items.len;
}
} else if (cell.hasText()) {
// Handle cells with grapheme clusters
const str = row.lookupGrapheme(col) catch continue;
temp_buf.appendSlice(str) catch return 0;
last_non_space = temp_buf.items.len;
}
}
// Trim trailing spaces from the line
if (last_non_space > line_start) {
temp_buf.shrinkRetainingCapacity(last_non_space);
} else if (temp_buf.items.len > line_start) {
// Empty line with only spaces - remove it
temp_buf.shrinkRetainingCapacity(line_start);
}
// Add newline if not the last row and line has content
if (row_idx < t.rows - 1 and temp_buf.items.len > line_start) {
temp_buf.append('\n') catch return 0;
}
}
// Copy to output buffer
const result = temp_buf.items;
if (result.len > cap) return 0;
@memcpy(buf[0..result.len], result);
// Null terminate if there's room
if (result.len < cap) {
buf[result.len] = 0;
}
return result.len;
}
/// Tell the surface that it needs to schedule a render
export fn ghostty_surface_refresh(surface: *Surface) void {
surface.refresh();