mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
82
ACCESSIBILITY_IMPLEMENTATION.md
Normal file
82
ACCESSIBILITY_IMPLEMENTATION.md
Normal 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
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
Reference in New Issue
Block a user