macos: add selection range to ScreenText

Extend ScreenText to include the active selection's UTF-8 byte offsets
alongside the text and viewport. The selection range is computed as part
of the same formatter pass as the text, so they're guaranteed to be in
sync.

accessibilitySelectedTextRange now reads the cached selection range
directly, and we now return NSNotFound when there's no selection per the
NSAccessibility convention for read-only content.
pull/12881/head
Jon Parise 2026-05-31 21:09:04 -04:00
parent eed26686f6
commit 359d3d35f9
7 changed files with 360 additions and 140 deletions

View File

@ -419,6 +419,8 @@ typedef struct {
uintptr_t text_len;
uintptr_t viewport_start;
uintptr_t viewport_end;
uintptr_t selection_start;
uintptr_t selection_end;
} ghostty_screen_text_s;
typedef enum {

View File

@ -49,19 +49,21 @@ extension Ghostty {
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// Snapshot of the full screen text with viewport position
/// and line-start offsets precomputed in UTF-16 space (NSRange
/// semantics).
/// Snapshot of the full screen text with viewport position,
/// optional selection range, 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),
selectionRange: nil,
utf16Length: 0,
lineStarts: [0]
)
let text: String
let viewportRange: NSRange
let selectionRange: NSRange?
let utf16Length: Int
/// UTF-16 offsets of the start of each line. Always
@ -221,22 +223,35 @@ extension Ghostty.OSSurfaceView {
extension Ghostty.OSSurfaceView.ScreenText {
/// Build from a UTF-8 string and the byte offsets that delimit
/// the viewport, translating to UTF-16 / NSRange space.
/// the viewport and (optionally) the selection, translating to
/// UTF-16 / NSRange space. A zero-length selection input means
/// "no selection."
init(
text: String,
viewportStartByte: Int,
viewportEndByte: Int
viewportEndByte: Int,
selectionStartByte: Int = 0,
selectionEndByte: Int = 0
) {
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)
// Convert a UTF-8 byte range from the Zig side to a UTF-16
// NSRange, clamping out-of-range offsets to end-of-text.
func range(from startByte: Int, to endByte: Int) -> NSRange {
let start = utf8.index(
utf8.startIndex, offsetBy: startByte, limitedBy: utf8.endIndex
)?.utf16Offset(in: text) ?? utf16Length
let end = max(start, utf8.index(
utf8.startIndex, offsetBy: endByte, limitedBy: utf8.endIndex
)?.utf16Offset(in: text) ?? utf16Length)
return NSRange(location: start, length: end - start)
}
let viewportRange = range(from: viewportStartByte, to: viewportEndByte)
let selectionRange: NSRange? = selectionStartByte < selectionEndByte
? range(from: selectionStartByte, to: selectionEndByte)
: nil
// getLineStart's contentsEnd tells us whether the line ended
// with a terminator including at end-of-buffer, where a
@ -263,9 +278,8 @@ extension Ghostty.OSSurfaceView.ScreenText {
self.init(
text: text,
viewportRange: NSRange(
location: viewportStart,
length: viewportEnd - viewportStart),
viewportRange: viewportRange,
selectionRange: selectionRange,
utf16Length: utf16Length,
lineStarts: lineStarts
)

View File

@ -292,7 +292,9 @@ extension Ghostty {
return .init(
text: String(cString: cString),
viewportStartByte: Int(info.viewport_start),
viewportEndByte: Int(info.viewport_end)
viewportEndByte: Int(info.viewport_end),
selectionStartByte: Int(info.selection_start),
selectionEndByte: Int(info.selection_end)
)
}
@ -2297,38 +2299,8 @@ extension Ghostty.SurfaceView {
return cachedScreenText.get().text
}
/// 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 {
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
return cachedScreenText.get().selectionRange ?? NSRange(location: NSNotFound, length: 0)
}
/// Returns the currently selected text as a string.

View File

@ -3,8 +3,10 @@ import Foundation
import Testing
struct OSSurfaceViewScreenTextTests {
typealias ScreenText = Ghostty.OSSurfaceView.ScreenText
@Test func emptyTextProducesEmpty() {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "",
viewportStartByte: 0,
viewportEndByte: 0
@ -13,7 +15,7 @@ struct OSSurfaceViewScreenTextTests {
}
@Test func asciiOffsetsAreIdentity() {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "hello\nworld",
viewportStartByte: 6,
viewportEndByte: 11
@ -26,7 +28,7 @@ struct OSSurfaceViewScreenTextTests {
// 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.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: text,
viewportStartByte: 0,
viewportEndByte: text.utf8.count
@ -38,7 +40,7 @@ struct OSSurfaceViewScreenTextTests {
@Test func viewportRangeSkipsAcrossSurrogatePair() {
let text = "a😀b"
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: text,
viewportStartByte: 5, // byte index of "b"
viewportEndByte: 6 // byte index past "b"
@ -48,7 +50,7 @@ struct OSSurfaceViewScreenTextTests {
@Test func cjkCharacterCountsAsOneUTF16Unit() {
let text = ""
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: text,
viewportStartByte: 0,
viewportEndByte: text.utf8.count
@ -58,7 +60,7 @@ struct OSSurfaceViewScreenTextTests {
}
@Test func viewportPastEndClampsToEndOfText() {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "hi",
viewportStartByte: 10,
viewportEndByte: 20
@ -70,7 +72,7 @@ struct OSSurfaceViewScreenTextTests {
@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.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "abcdef",
viewportStartByte: 4,
viewportEndByte: 2
@ -85,7 +87,7 @@ struct OSSurfaceViewScreenTextTests {
from: text.utf8.startIndex,
to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)!
)
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: text,
viewportStartByte: start,
viewportEndByte: start + viewport.utf8.count
@ -107,7 +109,7 @@ struct OSSurfaceViewScreenTextTests {
("a\nb\n", [0, 2, 4]),
])
func lineStarts(text: String, expected: [Int]) {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count
)
#expect(screenText.lineStarts == expected)
@ -123,14 +125,14 @@ struct OSSurfaceViewScreenTextTests {
(100, 2) // way past end still clamps
])
func lineAtIndex(index: Int, expected: Int) {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5
)
#expect(screenText.line(at: index) == expected)
}
@Test func lineAtNegativeIndexClampsToZero() {
let screenText = Ghostty.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "a\nb", viewportStartByte: 0, viewportEndByte: 3
)
#expect(screenText.line(at: -1) == 0)
@ -139,9 +141,53 @@ struct OSSurfaceViewScreenTextTests {
@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.OSSurfaceView.ScreenText(
let screenText = ScreenText(
text: "", viewportStartByte: 0, viewportEndByte: 0
)
#expect(screenText.line(at: 100) == 0)
}
// MARK: selectionRange
@Test(arguments: [
(2, 2), // zero-length (also the default-args case)
(4, 2), // reversed
])
func selectionMustBePositive(startByte: Int, endByte: Int) {
let screenText = ScreenText(
text: "hello",
viewportStartByte: 0, viewportEndByte: 5,
selectionStartByte: startByte, selectionEndByte: endByte
)
#expect(screenText.selectionRange == nil)
}
@Test func selectionAsciiOffsetsAreIdentity() {
let screenText = ScreenText(
text: "hello world",
viewportStartByte: 0, viewportEndByte: 11,
selectionStartByte: 6, selectionEndByte: 11
)
#expect(screenText.selectionRange == NSRange(location: 6, length: 5))
}
@Test func selectionAcrossSurrogatePair() {
let text = "a😀b"
let screenText = ScreenText(
text: text,
viewportStartByte: 0, viewportEndByte: text.utf8.count,
selectionStartByte: 1, selectionEndByte: 5 // "😀"
)
// "😀" occupies a UTF-16 surrogate pair (locations 1..3).
#expect(screenText.selectionRange == NSRange(location: 1, length: 2))
}
@Test func selectionPastEndClampsToEndOfText() {
let screenText = ScreenText(
text: "hi",
viewportStartByte: 0, viewportEndByte: 2,
selectionStartByte: 10, selectionEndByte: 20
)
#expect(screenText.selectionRange == NSRange(location: 2, length: 0))
}
}

View File

@ -1,39 +0,0 @@
@testable import Ghostty
import Foundation
import Testing
struct SurfaceViewAccessibilityTests {
@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

@ -1311,6 +1311,8 @@ pub const CAPI = struct {
text_len: usize,
viewport_start: usize,
viewport_end: usize,
selection_start: usize,
selection_end: usize,
pub fn deinit(self: *ScreenText) void {
if (self.text) |ptr| {
@ -1696,10 +1698,11 @@ pub const CAPI = struct {
}
/// 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.
/// that delimit the visible viewport and (if `has_selection`) the
/// active selection, 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,
@ -1713,6 +1716,8 @@ pub const CAPI = struct {
.text_len = 0,
.viewport_start = 0,
.viewport_end = 0,
.selection_start = 0,
.selection_end = 0,
};
return false;
};
@ -1721,6 +1726,8 @@ pub const CAPI = struct {
.text_len = screen_text.text.len,
.viewport_start = screen_text.viewport.start,
.viewport_end = screen_text.viewport.end,
.selection_start = if (screen_text.selection) |s| s.start else 0,
.selection_end = if (screen_text.selection) |s| s.end else 0,
};
return true;
}

View File

@ -2511,13 +2511,14 @@ pub fn selectionString(
return text;
}
/// Full screen text (scrollback + active) with the UTF-8 byte range
/// delimiting the visible viewport.
/// Full screen text (scrollback + active) with UTF-8 byte ranges
/// delimiting the visible viewport and (if any) the active selection.
pub const ScreenText = struct {
text: [:0]const u8,
viewport: Viewport,
viewport: Range,
selection: ?Range,
pub const Viewport = struct {
pub const Range = struct {
start: usize,
end: usize,
};
@ -2559,31 +2560,48 @@ pub fn screenText(
// 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,
const range = struct {
fn locate(items: []const Pin, tl: Pin, br: Pin) ScreenText.Range {
const start = std.sort.partitionPoint(Pin, items, tl, struct {
fn pred(ctx: Pin, pin: Pin) bool {
return pin.before(ctx);
}
}.pred);
const end = start + std.sort.partitionPoint(
Pin,
items[start..],
br,
struct {
fn pred(ctx: Pin, pin: Pin) bool {
return !ctx.before(pin);
}
}.pred,
);
// Preserve `start <= end` when the range sits past end-of-text.
return .{ .start = @min(start, end), .end = end };
}
}.locate;
const viewport = range(
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,
);
const selection: ?ScreenText.Range = if (self.selection) |sel| sel: {
var r = range(pins.items, sel.topLeft(self), sel.bottomRight(self));
// The partition can include row-terminating newlines whose Pins
// the formatter assigned to satisfy empty lines in the text layout.
// Trim to match `selectionString`. This is safe because cell bytes
// are never '\n'.
while (r.end > r.start and text[r.end - 1] == '\n') r.end -= 1;
break :sel r;
} else null;
return .{
.text = text,
// Preserve `start <= end` when the viewport sits past end-of-text.
.viewport = .{ .start = @min(start, end), .end = end },
.viewport = viewport,
.selection = selection,
};
}
@ -8462,10 +8480,8 @@ test "Screen: selectWord" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
};
// Outside of active area
@ -8585,10 +8601,8 @@ test "Screen: selectWord across soft-wrap" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
};
{
@ -8659,10 +8673,8 @@ test "Screen: selectWord whitespace across soft-wrap" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
};
// Going forward
@ -8723,10 +8735,8 @@ test "Screen: selectWord with character boundary" {
// Default boundary codepoints for word selection
const boundary_codepoints = &[_]u21{
0, ' ', '\t', '\'', '"',
'│', '`', '|', ':', ';',
',', '(', ')', '[', ']',
'{', '}', '<', '>', '$',
0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';',
',', '(', ')', '[', ']', '{', '}', '<', '>', '$',
};
const cases = [_][]const u8{
@ -10649,3 +10659,211 @@ test "Screen: screenText wide character at viewport boundary" {
try testing.expect(result.text[result.viewport.end] & 0xC0 != 0x80);
}
}
test "Screen: screenText no selection" {
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");
const result = try s.screenText(alloc);
defer result.deinit(alloc);
try testing.expect(result.selection == null);
}
test "Screen: screenText selection inside viewport" {
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");
// Select "EFGH" (row 1, full content). End-of-row selections must
// not pull in the trailing newline.
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqualStrings("EFGH", result.text[sel.start..sel.end]);
}
test "Screen: screenText selection mid-row" {
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");
// Select "EFG" ends mid-row, no trailing newline to trim.
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqualStrings("EFG", result.text[sel.start..sel.end]);
}
test "Screen: screenText selection spanning multiple rows" {
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");
// Inter-row newlines are preserved; only the selection's trailing
// line terminator gets trimmed.
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqualStrings("ABCD\n2EFGH", result.text[sel.start..sel.end]);
}
test "Screen: screenText selection ending in empty row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 100 });
defer s.deinit();
// Row 1 is intentionally empty.
try s.testWriteString("1ABCD\n\n3IJKL");
// Select from row 0 col 0 ("1ABCD") through row 1 col 0 (the empty
// row). The empty row contributes no visible cells, so the result
// matches `selectionString` which emits "1ABCD" (no trailing rows).
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqualStrings("1ABCD", result.text[sel.start..sel.end]);
}
test "Screen: screenText selection of only blank rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 100 });
defer s.deinit();
// Rows 1 and 2 are intentionally empty.
try s.testWriteString("1ABCD\n\n\n4MNOP");
// Select only the two blank rows. The trim loop strips every
// trailing '\n', collapsing the range to zero length. The C API
// reports start == end as "no selection".
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 4, .y = 2 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqual(sel.start, sel.end);
}
test "Screen: screenText selection matches selectionString" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 100 });
defer s.deinit();
try s.testWriteString("line one\n\nline three");
// Select "line one" + the empty row's first cell.
const sel = Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
false,
);
try s.select(sel);
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel_str = try s.selectionString(alloc, .{ .sel = sel, .trim = false });
defer alloc.free(sel_str);
const r = result.selection.?;
try testing.expectEqualStrings(sel_str, result.text[r.start..r.end]);
}
test "Screen: screenText selection in scrollback" {
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");
// Active rows are "3IJKL", "4MNOP", "5QRST"; "1ABCD" and "2EFGH"
// are in scrollback. Select "2EFGH" via screen-space pinning to
// confirm offsets index into the full snapshot.
const start = s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const end = s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?;
try s.select(Selection.init(start, end, false));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
try testing.expectEqualStrings("2EFGH", result.text[sel.start..sel.end]);
}
test "Screen: screenText selection with wide character" {
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("好kAB");
// Select the entire row: "" (cols 0,1, wide), "k" (col 2),
// "A" (col 3), "B" (col 4, end-of-row).
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?,
false,
));
const result = try s.screenText(alloc);
defer result.deinit(alloc);
const sel = result.selection.?;
// Selection offsets land on UTF-8 codepoint boundaries.
try testing.expect(result.text[sel.start] & 0xC0 != 0x80);
if (sel.end < result.text.len) {
try testing.expect(result.text[sel.end] & 0xC0 != 0x80);
}
try testing.expectEqualStrings("好kAB", result.text[sel.start..sel.end]);
}