diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..a0933e556 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index bc822cbd9..f117ed23d 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -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 + ) + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 8c22c0cdf..0cc194778 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -209,6 +209,7 @@ extension Ghostty { // The cached contents of the screen. private(set) var cachedScreenContents: CachedValue private(set) var cachedVisibleContents: CachedValue + private(set) var cachedScreenText: CachedValue /// 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]) } diff --git a/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift new file mode 100644 index 000000000..b38351ba3 --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift @@ -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)) + } +} diff --git a/src/Surface.zig b/src/Surface.zig index 99c740c89..bb8333597 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..80e454d98 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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(); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8ee700252..e08eb2ba0 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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]); +}