Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,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,
Expand Down Expand Up @@ -1130,6 +1137,11 @@ bool ghostty_surface_read_text(ghostty_surface_t,
ghostty_text_s*);
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__
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
void* ghostty_surface_quicklook_font(ghostty_surface_t);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
180 changes: 118 additions & 62 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,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?
Expand All @@ -263,54 +283,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(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
Expand Down Expand Up @@ -2192,12 +2200,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()
}
Expand All @@ -2207,7 +2213,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) }
Expand All @@ -2217,31 +2223,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])
}
Expand All @@ -2259,7 +2315,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()
Expand Down
18 changes: 18 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2009,6 +2009,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();
Expand Down
49 changes: 49 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,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();
Expand Down
Loading