macos: fix AXVisibleCharacterRange and UTF-16 indexing

Several accessibility methods on SurfaceView returned values in the
wrong coordinate system, which made the visible-range and selection
APIs unusable across screen readers, translation tools, and AI
autocomplete apps:

- accessibilityVisibleCharacterRange returned the full scrollback as
  the visible range. AX clients would fetch hundreds of KB to translate
  a single sentence on screen.
- accessibilityNumberOfCharacters reported grapheme count rather than
  UTF-16 code units. Any range derived from it indexed into the wrong
  bytes when the buffer contained emoji.
- accessibilityLine(for:) treated the parameter as a grapheme index,
  same problem.
- accessibilitySelectedTextRange returned viewport-cell-linear offsets
  (y*cols+x), which are unrelated to UTF-16 NSRange semantics. The
  selection's range and text disagreed for non-ASCII content.

The main idea is a self-consistent screen-text snapshot. The Zig core
adds Screen.screenText which uses ScreenFormatter directly to emit
the full screen along with a per-byte Pin array, then binary-searches
the array for the viewport's start/end byte offsets. The C API exports
this as ghostty_surface_read_screen / ghostty_screen_text_s.

The macOS layer caches a Ghostty.SurfaceView.ScreenText snapshot that
holds the text, the viewport NSRange in UTF-16, the total UTF-16
length, and a precomputed lineStarts table. The AX overrides read from
this single cache so all reported lengths and ranges live in the same
coordinate system.

accessibilitySelectedTextRange searches the cached text for the
selection's bytes and returns the unique match, or NSNotFound when the
selection appears more than once. A wrong-but-valid range would mislead
AX clients more than NSNotFound.

The preexisting cachedScreenContents and cachedVisibleContents stay in
place because GetTerminalDetailsIntent still reads them. Folding those
into the new cache can be a separate cleanup.

The overall result was verified using some small AX scripts to confirm
correct AXVisibleCharacterRange, AXNumberOfCharacters, AXStringForRange,
and AXSelectedTextRange behavior.

Note that AXRangeForPosition and AXBoundsForRange are still not yet
implemented. Those need a little more foundational work and can come in
follow-up changes.
pull/12881/head
Jon Parise 2026-05-31 15:47:45 -04:00
parent 16f2fdc90c
commit 6dc8e23046
6 changed files with 585 additions and 29 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_screen_text_s;
typedef enum {
GHOSTTY_POINT_ACTIVE,
GHOSTTY_POINT_VIEWPORT,
@ -1161,6 +1168,10 @@ GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
ghostty_selection_s,
ghostty_text_s*);
GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
GHOSTTY_API bool ghostty_surface_read_screen(ghostty_surface_t,
ghostty_screen_text_s*);
GHOSTTY_API void ghostty_surface_free_screen_text(ghostty_surface_t,
ghostty_screen_text_s*);
#ifdef __APPLE__
GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);

View File

@ -210,6 +210,46 @@ extension Ghostty {
private(set) var cachedScreenContents: CachedValue<String>
private(set) var cachedVisibleContents: CachedValue<String>
/// Snapshot of the full screen text with viewport position
/// and line-start offsets precomputed in UTF-16 space (NSRange
/// semantics).
struct ScreenText: Equatable {
static let empty = ScreenText(
text: "",
viewportRange: NSRange(location: 0, length: 0),
utf16Length: 0,
lineStarts: [0]
)
let text: String
let viewportRange: NSRange
let utf16Length: Int
/// UTF-16 offsets of the start of each line. Always
/// non-empty: index 0 is 0 even for an empty string.
let lineStarts: [Int]
/// Line number (0-based) containing the given UTF-16
/// offset. Out-of-range offsets clamp to the nearest
/// valid position.
func line(at index: Int) -> Int {
let clamped = max(0, min(index, utf16Length))
// Upper-bound search over lineStarts.
var lo = 0
var hi = lineStarts.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if lineStarts[mid] <= clamped {
lo = mid + 1
} else {
hi = mid
}
}
return lo - 1
}
}
private(set) var cachedScreenText: CachedValue<ScreenText>
/// Event monitor (see individual events for why)
private var eventMonitor: Any?
@ -231,6 +271,7 @@ extension Ghostty {
// fix at some point.
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
self.cachedVisibleContents = self.cachedScreenContents
self.cachedScreenText = .init(duration: .milliseconds(500)) { .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
@ -278,6 +319,21 @@ extension Ghostty {
defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.text)
}
cachedScreenText = .init(duration: .milliseconds(500)) { [weak self] in
guard let self else { return .empty }
guard let surface = self.surface else { return .empty }
var info = ghostty_screen_text_s()
guard ghostty_surface_read_screen(surface, &info) else {
return .empty
}
defer { ghostty_surface_free_screen_text(surface, &info) }
guard let cString = info.text else { return .empty }
return ScreenText(
text: String(cString: cString),
viewportStartByte: Int(info.viewport_start),
viewportEndByte: Int(info.viewport_end)
)
}
// Set a timer to show the ghost emoji after 500ms if no title is set
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
@ -2256,6 +2312,60 @@ extension Ghostty.SurfaceView {
// MARK: Accessibility
extension Ghostty.SurfaceView.ScreenText {
/// Build from a UTF-8 string and the byte offsets that delimit
/// the viewport, translating to UTF-16 / NSRange space.
init(
text: String,
viewportStartByte: Int,
viewportEndByte: Int
) {
let utf8 = text.utf8
let utf16Length = text.utf16.count
// Convert the viewport from UTF-8 bytes to UTF-16 offsets.
let viewportStart = utf8.index(
utf8.startIndex, offsetBy: viewportStartByte, limitedBy: utf8.endIndex
)?.utf16Offset(in: text) ?? utf16Length
let viewportEnd = max(viewportStart, utf8.index(
utf8.startIndex, offsetBy: viewportEndByte, limitedBy: utf8.endIndex
)?.utf16Offset(in: text) ?? utf16Length)
// getLineStart's contentsEnd tells us whether the line ended
// with a terminator including at end-of-buffer, where a
// trailing terminator means an extra empty line AX clients can
// navigate to. Every Unicode line terminator (LF, CR, CRLF,
// NEL, LS, PS) counts.
let string = text as NSString
var lineStarts: [Int] = [0]
var cursor = 0
while cursor < utf16Length {
var start = 0
var end = 0
var contentsEnd = 0
string.getLineStart(
&start,
end: &end,
contentsEnd: &contentsEnd,
for: NSRange(location: cursor, length: 0)
)
if end <= cursor { break }
if contentsEnd < end { lineStarts.append(end) }
cursor = end
}
self.init(
text: text,
viewportRange: NSRange(
location: viewportStart,
length: viewportEnd - viewportStart),
utf16Length: utf16Length,
lineStarts: lineStarts
)
}
}
extension Ghostty.SurfaceView {
/// Indicates that this view should be exposed to accessibility tools like VoiceOver.
/// By returning true, we make the terminal surface accessible to screen readers
@ -2277,14 +2387,41 @@ extension Ghostty.SurfaceView {
}
override func accessibilityValue() -> Any? {
return cachedScreenContents.get()
return cachedScreenText.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.
/// UTF-16 NSRange of the current selection within the cached
/// screen text, or `NSRange(NSNotFound, 0)` when no selection
/// exists or the selection text appears more than once.
override func accessibilitySelectedTextRange() -> NSRange {
return selectedRange()
guard let selected = accessibilitySelectedText(), !selected.isEmpty else {
return NSRange(location: 0, length: 0)
}
return Ghostty.SurfaceView.accessibilityRange(
of: selected, in: cachedScreenText.get().text)
}
/// UTF-16 NSRange of `needle` inside `haystack` when there is
/// exactly one occurrence. Returns `NSRange(NSNotFound, 0)` for
/// no match or a multiple-occurrence ambiguous match.
static func accessibilityRange(of needle: String, in haystack: String) -> NSRange {
guard !needle.isEmpty else { return NSRange(location: NSNotFound, length: 0) }
let string = haystack as NSString
let first = string.range(of: needle)
guard first.location != NSNotFound else {
return NSRange(location: NSNotFound, length: 0)
}
let searchStart = first.location + first.length
let rest = NSRange(
location: searchStart,
length: string.length - searchStart)
if rest.length > 0 {
let second = string.range(of: needle, range: rest)
if second.location != NSNotFound {
return NSRange(location: NSNotFound, length: 0)
}
}
return first
}
/// Returns the currently selected text as a string.
@ -2301,32 +2438,23 @@ extension Ghostty.SurfaceView {
return str.isEmpty ? nil : str
}
/// Returns the number of characters in the terminal content.
/// This helps assistive technologies understand the size of the content.
override func accessibilityNumberOfCharacters() -> Int {
let content = cachedScreenContents.get()
return content.count
return cachedScreenText.get().utf16Length
}
/// Returns the visible character range for the terminal.
/// For terminals, we typically show all content as visible.
override func accessibilityVisibleCharacterRange() -> NSRange {
let content = cachedScreenContents.get()
return NSRange(location: 0, length: content.count)
return cachedScreenText.get().viewportRange
}
/// Returns the line number for a given character index.
/// This helps assistive technologies navigate by line.
/// Logical/paragraph-line semantics matching `NSTextView`: lines are
/// delimited by hard newlines and soft-wrap is invisible to line
/// navigation (a soft-wrapped line is one line).
override func accessibilityLine(for index: Int) -> Int {
let content = cachedScreenContents.get()
let substring = String(content.prefix(index))
return substring.components(separatedBy: .newlines).count - 1
return cachedScreenText.get().line(at: index)
}
/// 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 = cachedScreenText.get().text
guard let swiftRange = Range(range, in: content) else { return nil }
return String(content[swiftRange])
}

View File

@ -0,0 +1,188 @@
@testable import Ghostty
import Foundation
import Testing
/// Tests for the pure-logic helpers that back the macOS accessibility
/// overrides on `SurfaceView`. The full instantiation path needs a
/// live `ghostty_app_t`, so we exercise the conversion layer that
/// translates UTF-8 byte offsets from the Zig core into UTF-16
/// (NSRange) space.
struct SurfaceViewAccessibilityTests {
@Test func emptyTextProducesEmpty() {
let screenText = Ghostty.SurfaceView.ScreenText(
text: "",
viewportStartByte: 0,
viewportEndByte: 0
)
#expect(screenText == .empty)
}
@Test func asciiOffsetsAreIdentity() {
let screenText = Ghostty.SurfaceView.ScreenText(
text: "hello\nworld",
viewportStartByte: 6,
viewportEndByte: 11
)
#expect(screenText.utf16Length == 11)
#expect(screenText.viewportRange == NSRange(location: 6, length: 5))
}
@Test func emojiCountsAsTwoUTF16Units() {
// U+1F600 ("😀") is 4 bytes in UTF-8 and a surrogate pair (two
// code units) in UTF-16.
let text = "a😀b"
let screenText = Ghostty.SurfaceView.ScreenText(
text: text,
viewportStartByte: 0,
viewportEndByte: text.utf8.count
)
#expect(text.count == 3)
#expect(screenText.utf16Length == 4)
#expect(screenText.viewportRange == NSRange(location: 0, length: 4))
}
@Test func viewportRangeSkipsAcrossSurrogatePair() {
let text = "a😀b"
let screenText = Ghostty.SurfaceView.ScreenText(
text: text,
viewportStartByte: 5, // byte index of "b"
viewportEndByte: 6 // byte index past "b"
)
#expect(screenText.viewportRange == NSRange(location: 3, length: 1))
}
@Test func cjkCharacterCountsAsOneUTF16Unit() {
let text = ""
let screenText = Ghostty.SurfaceView.ScreenText(
text: text,
viewportStartByte: 0,
viewportEndByte: text.utf8.count
)
#expect(screenText.utf16Length == 1)
#expect(screenText.viewportRange == NSRange(location: 0, length: 1))
}
@Test func viewportPastEndClampsToEndOfText() {
let screenText = Ghostty.SurfaceView.ScreenText(
text: "hi",
viewportStartByte: 10,
viewportEndByte: 20
)
#expect(screenText.viewportRange.location == 2)
#expect(screenText.viewportRange.length == 0)
}
@Test func reversedOffsetsCollapseToZeroLength() {
// A negative NSRange.length is meaningless to AX clients; the
// init normalizes to a zero-length range at start.
let screenText = Ghostty.SurfaceView.ScreenText(
text: "abcdef",
viewportStartByte: 4,
viewportEndByte: 2
)
#expect(screenText.viewportRange.length == 0)
}
@Test func viewportInMiddleOfPureAscii() {
let text = "scrollback\nviewport line\nmore content"
let viewport = "viewport line\n"
let start = text.utf8.distance(
from: text.utf8.startIndex,
to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)!
)
let screenText = Ghostty.SurfaceView.ScreenText(
text: text,
viewportStartByte: start,
viewportEndByte: start + viewport.utf8.count
)
#expect(screenText.utf16Length == (text as NSString).length)
#expect(screenText.viewportRange.length == (viewport as NSString).length)
}
// MARK: lineStarts
@Test(arguments: [
// Empty-text case is covered by emptyTextProducesEmpty.
("hello", [0]),
("a\nb\nc", [0, 2, 4]),
// CRLF is a single line terminator, not two.
("a\r\nb", [0, 3]),
// Trailing newline creates an empty trailing line so the
// past-end cursor is reported on its own line.
("a\nb\n", [0, 2, 4]),
])
func lineStarts(text: String, expected: [Int]) {
let screenText = Ghostty.SurfaceView.ScreenText(
text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count
)
#expect(screenText.lineStarts == expected)
}
@Test(arguments: [
(0, 0), // before first char
(1, 0), // on the '\n'
(2, 1), // start of second line
(3, 1), // inside second line
(4, 2), // start of third line
(5, 2), // past end clamps to last line
(100, 2) // way past end still clamps
])
func lineAtIndex(index: Int, expected: Int) {
let screenText = Ghostty.SurfaceView.ScreenText(
text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5
)
#expect(screenText.line(at: index) == expected)
}
@Test func lineAtNegativeIndexClampsToZero() {
let screenText = Ghostty.SurfaceView.ScreenText(
text: "a\nb", viewportStartByte: 0, viewportEndByte: 3
)
#expect(screenText.line(at: -1) == 0)
}
@Test func lineForEmptyText() {
// Past-end indices clamp to line 0 when the text is empty;
// the in-range `at: 0` case is covered by lineAtIndex.
let screenText = Ghostty.SurfaceView.ScreenText(
text: "", viewportStartByte: 0, viewportEndByte: 0
)
#expect(screenText.line(at: 100) == 0)
}
// MARK: accessibilityRange(of:in:)
@Test func accessibilityRangeFindsLoneOccurrence() {
let range = Ghostty.SurfaceView.accessibilityRange(
of: "selected",
in: "before selected after")
#expect(range == NSRange(location: 7, length: 8))
}
@Test func accessibilityRangeRejectsMultipleMatches() {
// `ls\n` appears twice in the haystack.
let range = Ghostty.SurfaceView.accessibilityRange(
of: "ls\n",
in: "$ ls\nfoo bar\n$ ls\nbaz")
#expect(range.location == NSNotFound)
#expect(range.length == 0)
}
@Test(arguments: [
"nope", // absent
"", // empty
])
func accessibilityRangeReturnsNotFoundForBadNeedle(needle: String) {
let range = Ghostty.SurfaceView.accessibilityRange(of: needle, in: "haystack")
#expect(range.location == NSNotFound)
#expect(range.length == 0)
}
@Test func accessibilityRangeHandlesSupplementaryPlane() {
// "😀" is two UTF-16 units (surrogate pair).
let range = Ghostty.SurfaceView.accessibilityRange(
of: "😀",
in: "x😀y")
#expect(range == NSRange(location: 1, length: 2))
}
}

View File

@ -2031,6 +2031,17 @@ pub fn dumpTextLocked(
};
}
/// Returns the full screen text and the UTF-8 byte offsets that
/// delimit the visible viewport, as a self-consistent snapshot.
pub fn screenText(
self: *Surface,
alloc: Allocator,
) !terminal.Screen.ScreenText {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
return try self.io.terminal.screens.active.screenText(alloc);
}
/// Returns true if the terminal has a selection.
pub fn hasSelection(self: *const Surface) bool {
self.renderer_state.mutex.lock();

View File

@ -1305,6 +1305,20 @@ pub const CAPI = struct {
}
};
// ghostty_screen_text_s
const ScreenText = extern struct {
text: ?[*:0]const u8,
text_len: usize,
viewport_start: usize,
viewport_end: usize,
pub fn deinit(self: *ScreenText) void {
if (self.text) |ptr| {
global.alloc.free(ptr[0..self.text_len :0]);
}
}
};
// ghostty_point_s
const Point = extern struct {
tag: Tag,
@ -1681,6 +1695,43 @@ pub const CAPI = struct {
ptr.deinit();
}
/// Read the full screen text along with the UTF-8 byte offsets
/// that delimit the visible viewport, as a self-consistent
/// snapshot. On success free with ghostty_surface_free_screen_text.
/// On failure `*result` is zero-initialized so calling
/// ghostty_surface_free_screen_text on it is a safe no-op.
export fn ghostty_surface_read_screen(
surface: *Surface,
result: *ScreenText,
) bool {
const screen_text = surface.core_surface.screenText(
global.alloc,
) catch |err| {
log.warn("error reading screen text err={}", .{err});
result.* = .{
.text = null,
.text_len = 0,
.viewport_start = 0,
.viewport_end = 0,
};
return false;
};
result.* = .{
.text = screen_text.text.ptr,
.text_len = screen_text.text.len,
.viewport_start = screen_text.viewport.start,
.viewport_end = screen_text.viewport.end,
};
return true;
}
export fn ghostty_surface_free_screen_text(
_: *Surface,
ptr: *ScreenText,
) void {
ptr.deinit();
}
/// 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,82 @@ pub fn selectionString(
return text;
}
/// Full screen text (scrollback + active) with the UTF-8 byte range
/// delimiting the visible viewport.
pub const ScreenText = struct {
text: [:0]const u8,
viewport: Viewport,
pub const Viewport = struct {
start: usize,
end: usize,
};
/// Slice of `text` containing the visible bytes
pub fn visible(self: ScreenText) []const u8 {
return self.text[self.viewport.start..self.viewport.end];
}
pub fn deinit(self: ScreenText, alloc: Allocator) void {
alloc.free(self.text);
}
};
pub fn screenText(
self: *Screen,
alloc: Allocator,
) Allocator.Error!ScreenText {
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
var pins: std.ArrayList(Pin) = .empty;
defer pins.deinit(alloc);
var formatter: ScreenFormatter = .init(self, .{
.emit = .plain,
.unwrap = true,
.trim = false,
});
formatter.pin_map = .{ .alloc = alloc, .map = &pins };
formatter.format(&aw.writer) catch return error.OutOfMemory;
const text = try aw.toOwnedSliceSentinel(0);
errdefer alloc.free(text);
// The formatter's contract: one Pin per byte written.
assert(pins.items.len == text.len);
// pins.items is monotonic in Pin order (the formatter emits in
// document order). Pin.before is O(pages) for cross-node
// comparisons, so we use binary search rather than a linear scan.
const start = std.sort.partitionPoint(
Pin,
pins.items,
self.pages.getTopLeft(.viewport),
struct {
fn pred(ctx: Pin, pin: Pin) bool {
return pin.before(ctx);
}
}.pred,
);
const end = start + std.sort.partitionPoint(
Pin,
pins.items[start..],
self.pages.getBottomRight(.viewport).?,
struct {
fn pred(ctx: Pin, pin: Pin) bool {
return !ctx.before(pin);
}
}.pred,
);
return .{
.text = text,
// Preserve `start <= end` when the viewport sits past end-of-text.
.viewport = .{ .start = @min(start, end), .end = end },
};
}
pub const SelectLine = struct {
/// The pin of some part of the line to select.
pin: Pin,
@ -8386,8 +8462,10 @@ test "Screen: selectWord" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
};
// Outside of active area
@ -8507,8 +8585,10 @@ test "Screen: selectWord across soft-wrap" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
};
{
@ -8579,8 +8659,10 @@ test "Screen: selectWord whitespace across soft-wrap" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
};
// Going forward
@ -8641,8 +8723,10 @@ test "Screen: selectWord with character boundary" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
};
const cases = [_][]const u8{
@ -10482,3 +10566,86 @@ 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);
}
test "Screen: screenText viewport at bottom" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
const result = try s.screenText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST", result.text);
try testing.expectEqualStrings("3IJKL\n4MNOP\n5QRST", result.visible());
}
test "Screen: screenText viewport scrolled to top" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
s.scroll(.{ .top = {} });
const result = try s.screenText(alloc);
defer result.deinit(alloc);
// The trailing newline of the last viewport row is mapped to the
// viewport row, not to the row after, so it sits inside the range.
try testing.expectEqual(@as(usize, 0), result.viewport.start);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n", result.visible());
}
test "Screen: screenText viewport scrolled mid-buffer" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
s.scroll(.{ .delta_row = -1 });
const result = try s.screenText(alloc);
defer result.deinit(alloc);
try testing.expectEqualStrings("2EFGH\n3IJKL\n4MNOP\n", result.visible());
}
test "Screen: screenText empty screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
defer s.deinit();
const result = try s.screenText(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: screenText wide character at viewport boundary" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n好kAB\n4MNOP\n5QRST");
const result = try s.screenText(alloc);
defer result.deinit(alloc);
// Viewport offsets must land on UTF-8 codepoint boundaries.
try testing.expect(result.text[result.viewport.start] & 0xC0 != 0x80);
if (result.viewport.end < result.text.len) {
try testing.expect(result.text[result.viewport.end] & 0xC0 != 0x80);
}
}