macOS: introduce AccessibilityContext for terminal accessibility
Add a new AccessibilityContext type (Screen.zig) that captures the full terminal text and viewport byte offsets in a single PinMap pass. This replaces the two separate ghostty_surface_read_text calls (screen + viewport extents) with one context that provides both, halving the work. The PinMap is needed because accessibilityVisibleCharacterRange must report the viewport as a range within the full text — without it, the previous implementation simply returned the range of the entire text. Swift-side changes: - Replace cachedScreenContents + cachedVisibleContents with a single cachedScreenTextInfo backed by the new context - Fix accessibilityNumberOfCharacters: return UTF-16 length instead of grapheme cluster count (NSRange operates on UTF-16 offsets) - Fix accessibilityVisibleCharacterRange: return viewport range instead of full text range - Fix accessibilityLine(for:): proper UTF-16 index conversion with defensive clamping for out-of-range values - Add accessibilityRange(forLine:): new override enabling VoiceOver line navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/11196/head
parent
dd575c7160
commit
7ca349ced1
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2494,6 +2494,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,
|
||||
|
|
@ -10350,3 +10508,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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue