pull/11196/merge
Daniel Gräfe 2026-06-03 03:04:44 +02:00 committed by GitHub
commit fea629f33e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 485 additions and 64 deletions

View File

@ -414,6 +414,13 @@ typedef struct {
uintptr_t text_len;
} ghostty_text_s;
typedef struct {
const char* text;
uintptr_t text_len;
uintptr_t viewport_start;
uintptr_t viewport_end;
} ghostty_ax_text_s;
typedef enum {
GHOSTTY_POINT_ACTIVE,
GHOSTTY_POINT_VIEWPORT,
@ -1162,6 +1169,11 @@ GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
ghostty_text_s*);
GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
typedef void* ghostty_ax_context_t;
ghostty_ax_context_t ghostty_surface_ax_context_new(ghostty_surface_t);
void ghostty_surface_ax_context_free(ghostty_ax_context_t);
bool ghostty_ax_context_info(ghostty_ax_context_t, ghostty_ax_text_s*);
#ifdef __APPLE__
GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t);

View File

@ -37,13 +37,13 @@ struct GetTerminalDetailsIntent: AppIntent {
case .workingDirectory: return .result(value: terminal.workingDirectory)
case .allContents:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
return .result(value: view.cachedScreenContents.get())
return .result(value: view.screenContents)
case .selectedText:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
return .result(value: view.accessibilitySelectedText())
case .visibleText:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
return .result(value: view.cachedVisibleContents.get())
return .result(value: view.visibleContents)
}
}
}

View File

@ -207,8 +207,28 @@ extension Ghostty {
private var titleFromTerminal: String?
// The cached contents of the screen.
private(set) var cachedScreenContents: CachedValue<String>
private(set) var cachedVisibleContents: CachedValue<String>
/// Full screen text, derived from the cached accessibility context.
var screenContents: String { cachedScreenTextInfo.get().text }
/// Visible viewport text, extracted from the cached accessibility context.
var visibleContents: String {
let info = cachedScreenTextInfo.get()
return String(info.text[info.viewportRange])
}
/// Full screen text (scrollback + active area) with the viewport's
/// character range, used by accessibility overrides.
struct ScreenTextInfo {
static let empty = ScreenTextInfo(
text: "", viewportRange: "".startIndex..<"".endIndex)
let text: String
let viewportRange: Range<String.Index>
/// Length in UTF-16 code units, matching NSRange semantics.
var utf16Length: Int { (text as NSString).length }
}
private(set) var cachedScreenTextInfo: CachedValue<ScreenTextInfo>
/// Event monitor (see individual events for why)
private var eventMonitor: Any?
@ -229,54 +249,42 @@ extension Ghostty {
// We need to initialize this so it does something but we want to set
// it back up later so we can reference `self`. This is a hack we should
// fix at some point.
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
self.cachedVisibleContents = self.cachedScreenContents
self.cachedScreenTextInfo = .init(duration: .milliseconds(500)) {
ScreenTextInfo.empty
}
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 600))
// Our cache of screen data
cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in
guard let self else { return "" }
guard let surface = self.surface else { return "" }
var text = ghostty_text_s()
let sel = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_SCREEN,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0),
bottom_right: ghostty_point_s(
tag: GHOSTTY_POINT_SCREEN,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0),
rectangle: false)
guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.text)
}
cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in
guard let self else { return "" }
guard let surface = self.surface else { return "" }
var text = ghostty_text_s()
let sel = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0),
bottom_right: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0),
rectangle: false)
guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.text)
// Single cache for screen text with viewport range. Creates a Zig
// accessibility context that extracts both the full text and viewport
// byte offsets in one call (single PinMap build).
cachedScreenTextInfo = CachedValue<ScreenTextInfo>(duration: .milliseconds(500)) { [weak self] in
guard let self else { return .empty }
guard let surface = self.surface else { return .empty }
guard let ctxRaw = ghostty_surface_ax_context_new(surface) else { return .empty }
defer { ghostty_surface_ax_context_free(ctxRaw) }
var axText = ghostty_ax_text_s()
guard ghostty_ax_context_info(ctxRaw, &axText) else { return .empty }
let text = String(cString: axText.text)
// Convert UTF-8 byte offsets to String.Index range.
let vpStart = Int(axText.viewport_start)
let vpEnd = Int(axText.viewport_end)
let utf8 = text.utf8
let clampedStart = min(vpStart, utf8.count)
let clampedEnd = max(clampedStart, min(vpEnd, utf8.count))
let startIdx = utf8.index(utf8.startIndex, offsetBy: clampedStart)
let endIdx = utf8.index(utf8.startIndex, offsetBy: clampedEnd)
return ScreenTextInfo(
text: text, viewportRange: startIdx..<endIdx)
}
// Set a timer to show the ghost emoji after 500ms if no title is set
@ -2277,12 +2285,10 @@ extension Ghostty.SurfaceView {
}
override func accessibilityValue() -> Any? {
return cachedScreenContents.get()
return cachedScreenTextInfo.get().text
}
/// Returns the range of text that is currently selected in the terminal.
/// This allows VoiceOver and other assistive technologies to understand
/// what text the user has selected.
override func accessibilitySelectedTextRange() -> NSRange {
return selectedRange()
}
@ -2292,7 +2298,7 @@ extension Ghostty.SurfaceView {
override func accessibilitySelectedText() -> String? {
guard let surface = self.surface else { return nil }
// Attempt to read the selection
// Attempt to read the selection.
var text = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &text) else { return nil }
defer { ghostty_surface_free_text(surface, &text) }
@ -2302,31 +2308,81 @@ extension Ghostty.SurfaceView {
}
/// Returns the number of characters in the terminal content.
/// This helps assistive technologies understand the size of the content.
/// We use NSString.length (UTF-16 code unit count) rather than String.count
/// (grapheme cluster count) because NSRange used by all accessibility APIs
/// operates on UTF-16 offsets.
override func accessibilityNumberOfCharacters() -> Int {
let content = cachedScreenContents.get()
return content.count
return cachedScreenTextInfo.get().utf16Length
}
/// Returns the visible character range for the terminal.
/// For terminals, we typically show all content as visible.
/// Returns only the visible viewport range within the full text,
/// not the entire scrollback buffer.
override func accessibilityVisibleCharacterRange() -> NSRange {
let content = cachedScreenContents.get()
return NSRange(location: 0, length: content.count)
let info = cachedScreenTextInfo.get()
return NSRange(info.viewportRange, in: info.text)
}
/// Returns the line number for a given character index.
/// This helps assistive technologies navigate by line.
/// The `index` parameter is a UTF-16 code unit offset (NSRange convention),
/// so we must convert it to a Swift String.Index before slicing.
///
/// Note: counts `\n`-delimited lines, not visual screen rows.
/// Soft-wrapped lines are treated as a single line. Accurate
/// visual-line support requires grid mapping.
override func accessibilityLine(for index: Int) -> Int {
let content = cachedScreenContents.get()
let substring = String(content.prefix(index))
return substring.components(separatedBy: .newlines).count - 1
let info = cachedScreenTextInfo.get()
// AX callers may pass out-of-range values (including -1 / NSNotFound),
// so clamp defensively before building NSRange.
let clampedIndex = max(0, min(index, info.utf16Length))
let nsRange = NSRange(location: 0, length: clampedIndex)
guard let swiftRange = Range(nsRange, in: info.text) else { return 0 }
return info.text[..<swiftRange.upperBound].reduce(0) { count, ch in
count + (ch.isNewline ? 1 : 0)
}
}
/// Returns the character range for a given line number.
/// Walks the text counting newlines in UTF-16 space to find the start
/// and end of the requested line. Returns an NSRange excluding the
/// trailing newline (matching NSTextView).
///
/// Note: counts `\n`-delimited lines, not visual screen rows.
/// Soft-wrapped lines are treated as a single line. Accurate
/// visual-line support requires grid mapping.
override func accessibilityRange(forLine line: Int) -> NSRange {
let info = cachedScreenTextInfo.get()
let nsContent = info.text as NSString
let length = nsContent.length
var currentLine = 0
var lineStart = 0
var i = 0
while i < length {
if currentLine == line {
break
}
if nsContent.character(at: i) == 0x0A { // '\n'
currentLine += 1
lineStart = i + 1
}
i += 1
}
guard currentLine == line else {
return NSRange(location: NSNotFound, length: 0)
}
var lineEnd = lineStart
while lineEnd < length && nsContent.character(at: lineEnd) != 0x0A {
lineEnd += 1
}
return NSRange(location: lineStart, length: lineEnd - lineStart)
}
/// Returns a substring for the given range.
/// This allows assistive technologies to read specific portions of the content.
override func accessibilityString(for range: NSRange) -> String? {
let content = cachedScreenContents.get()
let content = cachedScreenTextInfo.get().text
guard let swiftRange = Range(range, in: content) else { return nil }
return String(content[swiftRange])
}
@ -2344,7 +2400,7 @@ extension Ghostty.SurfaceView {
var attributes: [NSAttributedString.Key: Any] = [:]
// Try to get the font from the surface
// Try to get the font from the surface.
if let fontRaw = ghostty_surface_quicklook_font(surface) {
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
attributes[.font] = font.takeUnretainedValue()

View File

@ -2031,6 +2031,24 @@ pub fn dumpTextLocked(
};
}
// ---------------------------------------------------------------
// Accessibility helpers
// ---------------------------------------------------------------
pub const AccessibilityContext = terminal.Screen.AccessibilityContext;
/// Creates a pre-computed accessibility context (text + viewport
/// range). The context is self-contained and can be used without
/// holding the terminal mutex.
pub fn createAccessibilityContext(
self: *Surface,
alloc: Allocator,
) !*AccessibilityContext {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
return self.io.terminal.screens.active.createAccessibilityContext(alloc);
}
/// Returns true if the terminal has a selection.
pub fn hasSelection(self: *const Surface) bool {
self.renderer_state.mutex.lock();

View File

@ -1681,6 +1681,55 @@ pub const CAPI = struct {
ptr.deinit();
}
// ghostty_ax_text_s text info read from an AccessibilityContext.
// The text pointer is borrowed from the context; the caller must
// not free it separately.
const AXText = extern struct {
text: ?[*:0]const u8,
text_len: usize,
viewport_start: usize,
viewport_end: usize,
};
/// Creates a pre-computed accessibility context containing
/// the terminal text and viewport range. The context is
/// self-contained and can be used for subsequent queries
/// without holding the terminal mutex.
/// Free with ghostty_surface_ax_context_free.
export fn ghostty_surface_ax_context_new(
surface: *Surface,
) ?*anyopaque {
const ctx = surface.core_surface.createAccessibilityContext(
global.alloc,
) catch |err| {
log.warn("error creating accessibility context err={}", .{err});
return null;
};
return ctx;
}
export fn ghostty_surface_ax_context_free(ctx_raw: *anyopaque) void {
const ctx: *CoreSurface.AccessibilityContext = @ptrCast(@alignCast(ctx_raw));
ctx.deinit();
}
/// Reads text and viewport info from a pre-built context.
/// The text pointer is borrowed it remains valid until the
/// context is freed.
export fn ghostty_ax_context_info(
ctx_raw: *anyopaque,
result: *AXText,
) bool {
const ctx: *const CoreSurface.AccessibilityContext = @ptrCast(@alignCast(ctx_raw));
result.* = .{
.text = ctx.text.ptr,
.text_len = ctx.text.len,
.viewport_start = ctx.viewport_start,
.viewport_end = ctx.viewport_end,
};
return true;
}
/// Tell the surface that it needs to schedule a render
export fn ghostty_surface_refresh(surface: *Surface) void {
surface.refresh();

View File

@ -2511,6 +2511,164 @@ pub fn selectionString(
return text;
}
// ---------------------------------------------------------------
// Accessibility helpers
// ---------------------------------------------------------------
/// Pre-computed accessibility snapshot containing the full terminal
/// text and the byte offsets delimiting the visible viewport.
/// Contains only owned data no Pins or page pointers so it is
/// safe to cache and use without holding the terminal mutex.
pub const AccessibilityContext = struct {
alloc: Allocator,
/// The terminal text (all scrollback + active area).
text: [:0]const u8,
/// Byte offsets within `text` that delimit the visible viewport.
viewport_start: usize,
viewport_end: usize,
pub fn deinit(self: *AccessibilityContext) void {
self.alloc.free(self.text);
self.alloc.destroy(self);
}
};
/// Builds an AccessibilityContext: generates the terminal text with
/// a PinMap, computes viewport byte boundaries, then discards the
/// PinMap. The returned context is fully self-contained and can be
/// used without the terminal mutex.
pub fn createAccessibilityContext(
self: *Screen,
alloc: Allocator,
) !*AccessibilityContext {
// Empty screen fast path.
const screen_tl = self.pages.getTopLeft(.screen);
const screen_br = self.pages.getBottomRight(.screen) orelse {
return try self.createEmptyContext(alloc);
};
const sel = Selection.init(screen_tl, screen_br, false);
// Generate text with a pin map so we can locate viewport boundaries.
var string_map: StringMap = undefined;
const text = try self.selectionString(alloc, .{
.sel = sel,
.trim = false,
.map = &string_map,
});
errdefer alloc.free(text);
defer {
alloc.free(string_map.string);
alloc.free(string_map.map);
}
// Build a node cumulative-row-offset lookup so we can
// convert any Pin to an absolute screen row in O(1).
var node_offsets = std.AutoHashMap(*PageList.List.Node, usize).init(alloc);
defer node_offsets.deinit();
{
var total: usize = 0;
var it = self.pages.pages.first;
while (it) |node| : (it = node.next) {
try node_offsets.put(node, total);
total += node.data.size.rows;
}
}
// Determine viewport boundaries by scanning the pin map. A binary
// search is possible (rows are monotonically non-decreasing) but
// wouldn't change overall complexity since building the PinMap is
// already O(n).
const vp_tl = self.pages.getTopLeft(.viewport);
const vp_br = self.pages.getBottomRight(.viewport);
var vp_start: usize = text.len;
var vp_end: usize = text.len;
if (vp_br != null) {
const vp_tl_screen = self.pages.pointFromPin(.screen, vp_tl);
const vp_br_screen = self.pages.pointFromPin(.screen, vp_br.?);
if (vp_tl_screen != null and vp_br_screen != null) {
const vp_tl_row = vp_tl_screen.?.coord().y;
const vp_br_row = vp_br_screen.?.coord().y;
for (string_map.map, 0..) |pin, i| {
const row_offset = node_offsets.get(pin.node) orelse continue;
const abs_row = row_offset + pin.y;
if (abs_row < vp_tl_row) continue;
// We'd expect the viewport to always start at column 0,
// but guard against the general case.
if (abs_row == vp_tl_row and pin.x < vp_tl.x) continue;
if (abs_row > vp_br_row or (abs_row == vp_br_row and pin.x > vp_br.?.x)) {
vp_end = i;
break;
}
if (vp_start > i) vp_start = i;
}
}
}
if (vp_start >= string_map.map.len) vp_start = text.len;
if (vp_end >= string_map.map.len) vp_end = text.len;
const ctx = try alloc.create(AccessibilityContext);
ctx.* = .{
.alloc = alloc,
.text = text,
.viewport_start = vp_start,
.viewport_end = vp_end,
};
return ctx;
}
fn createEmptyContext(
self: *Screen,
alloc: Allocator,
) !*AccessibilityContext {
_ = self;
const ctx = try alloc.create(AccessibilityContext);
ctx.* = .{
.alloc = alloc,
.text = try alloc.dupeZ(u8, ""),
.viewport_start = 0,
.viewport_end = 0,
};
return ctx;
}
/// Convenience wrapper that returns the text and viewport range
/// without exposing the full context object to callers that only
/// need these values.
pub const AccessibilityText = struct {
text: [:0]const u8,
viewport_start: usize,
viewport_end: usize,
pub fn deinit(self: *AccessibilityText, alloc: Allocator) void {
alloc.free(self.text);
}
};
pub fn accessibilityText(
self: *Screen,
alloc: Allocator,
) !AccessibilityText {
const ctx = try self.createAccessibilityContext(alloc);
defer alloc.destroy(ctx);
// Keep the text alive; only free the context wrapper.
return .{
.text = ctx.text,
.viewport_start = ctx.viewport_start,
.viewport_end = ctx.viewport_end,
};
}
pub const SelectLine = struct {
/// The pin of some part of the line to select.
pin: Pin,
@ -10508,3 +10666,131 @@ test "Screen: promptClickMove click right of input cursor on last char" {
try testing.expectEqual(@as(usize, 1), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
// ---------------------------------------------------------------
// Accessibility tests
// ---------------------------------------------------------------
test "Screen: accessibilityText basic no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("hello\nworld\nfoo");
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("hello\nworld\nfoo", result.text);
// No scrollback: viewport == entire screen.
try testing.expectEqual(@as(usize, 0), result.viewport_start);
try testing.expectEqual(result.text.len, result.viewport_end);
}
test "Screen: accessibilityText scrollback viewport at bottom" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 });
defer s.deinit();
// Write 5 lines into a 3-row screen: lines 0-1 go to scrollback,
// lines 2-4 are in the active/viewport area.
try s.testWriteString("line0\nline1\nline2\nline3\nline4");
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
// Full text includes all 5 lines.
try testing.expectEqualStrings("line0\nline1\nline2\nline3\nline4", result.text);
// Viewport should cover lines 2-4 (the bottom 3 rows).
const vp_text = result.text[result.viewport_start..result.viewport_end];
try testing.expectEqualStrings("line2\nline3\nline4", vp_text);
}
test "Screen: accessibilityText scrollback scrolled up" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 });
defer s.deinit();
try s.testWriteString("line0\nline1\nline2\nline3\nline4");
// Scroll up by 2 rows so viewport shows lines 0-2.
s.scroll(.{ .delta_row = -2 });
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("line0\nline1\nline2\nline3\nline4", result.text);
const vp_text = result.text[result.viewport_start..result.viewport_end];
// The trailing newline is included because the newline between
// the last visible row and the first non-visible row is still
// mapped to a pin within the viewport's row range.
try testing.expectEqualStrings("line0\nline1\nline2\n", vp_text);
}
test "Screen: accessibilityText wide characters" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("A⚡B");
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("A⚡B", result.text);
try testing.expectEqual(@as(usize, 0), result.viewport_start);
try testing.expectEqual(result.text.len, result.viewport_end);
}
test "Screen: accessibilityText soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
// 5 columns, so "1234567890" soft-wraps across 2 rows.
var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("1234567890");
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
// Soft-wrapped lines do NOT produce a '\n' in the output.
try testing.expectEqualStrings("1234567890", result.text);
}
test "Screen: accessibilityText empty screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("", result.text);
try testing.expectEqual(@as(usize, 0), result.viewport_start);
try testing.expectEqual(@as(usize, 0), result.viewport_end);
}
test "Screen: accessibilityText partially filled" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
try s.testWriteString("hello");
var result = try s.accessibilityText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("hello", result.text);
try testing.expectEqual(@as(usize, 0), result.viewport_start);
try testing.expectEqual(result.text.len, result.viewport_end);
}