diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..3dbc866b7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index f631c2c05..0958e3d0c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -210,6 +210,46 @@ extension Ghostty { private(set) var cachedScreenContents: CachedValue private(set) var cachedVisibleContents: CachedValue + /// 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 + /// 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]) } diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift new file mode 100644 index 000000000..6a12db8be --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift @@ -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)) + } +} diff --git a/src/Surface.zig b/src/Surface.zig index 410f717b0..22c2d21a7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7310159cc..6ed1201c8 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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(); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index becda78b7..856d6ee02 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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); + } +}