pull/12881/merge
Jon Parise 2026-06-03 12:27:01 -07:00 committed by GitHub
commit 8049c6a0bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 794 additions and 21 deletions

View File

@ -414,6 +414,15 @@ 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;
uintptr_t selection_start;
uintptr_t selection_end;
} ghostty_screen_text_s;
typedef enum {
GHOSTTY_POINT_ACTIVE,
GHOSTTY_POINT_VIEWPORT,
@ -1161,6 +1170,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

@ -49,6 +49,47 @@ 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,
/// 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
/// 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
}
}
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
@ -179,3 +220,68 @@ extension Ghostty.OSSurfaceView {
return true
}
}
extension Ghostty.OSSurfaceView.ScreenText {
/// Build from a UTF-8 string and the byte offsets that delimit
/// 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,
selectionStartByte: Int = 0,
selectionEndByte: Int = 0
) {
let utf8 = text.utf8
let utf16Length = text.utf16.count
// 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
// 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: viewportRange,
selectionRange: selectionRange,
utf16Length: utf16Length,
lineStarts: lineStarts
)
}
}

View File

@ -209,6 +209,7 @@ extension Ghostty {
// The cached contents of the screen.
private(set) var cachedScreenContents: CachedValue<String>
private(set) var cachedVisibleContents: CachedValue<String>
private(set) var cachedScreenText: CachedValue<OSSurfaceView.ScreenText>
/// Event monitor (see individual events for why)
private var eventMonitor: Any?
@ -231,6 +232,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 +280,23 @@ 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 .init(
text: String(cString: cString),
viewportStartByte: Int(info.viewport_start),
viewportEndByte: Int(info.viewport_end),
selectionStartByte: Int(info.selection_start),
selectionEndByte: Int(info.selection_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
@ -2277,14 +2296,11 @@ 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.
override func accessibilitySelectedTextRange() -> NSRange {
return selectedRange()
return cachedScreenText.get().selectionRange ?? NSRange(location: NSNotFound, length: 0)
}
/// Returns the currently selected text as a string.
@ -2301,32 +2317,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,193 @@
@testable import Ghostty
import Foundation
import Testing
struct OSSurfaceViewScreenTextTests {
typealias ScreenText = Ghostty.OSSurfaceView.ScreenText
@Test func emptyTextProducesEmpty() {
let screenText = ScreenText(
text: "",
viewportStartByte: 0,
viewportEndByte: 0
)
#expect(screenText == .empty)
}
@Test func asciiOffsetsAreIdentity() {
let screenText = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = ScreenText(
text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5
)
#expect(screenText.line(at: index) == expected)
}
@Test func lineAtNegativeIndexClampsToZero() {
let screenText = 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 = 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

@ -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,22 @@ 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,
selection_start: usize,
selection_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 +1697,48 @@ pub const CAPI = struct {
ptr.deinit();
}
/// Read the full screen text along with the UTF-8 byte offsets
/// 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,
) 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,
.selection_start = 0,
.selection_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,
.selection_start = if (screen_text.selection) |s| s.start else 0,
.selection_end = if (screen_text.selection) |s| s.end else 0,
};
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,100 @@ pub fn selectionString(
return text;
}
/// 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: Range,
selection: ?Range,
pub const Range = 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 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),
self.pages.getBottomRight(.viewport).?,
);
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,
.viewport = viewport,
.selection = selection,
};
}
pub const SelectLine = struct {
/// The pin of some part of the line to select.
pin: Pin,
@ -10508,3 +10602,294 @@ 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);
}
}
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]);
}