From 359d3d35f9a5b852a13ce924df93101e3d3869f2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 21:09:04 -0400 Subject: [PATCH] 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. --- include/ghostty.h | 2 + .../Ghostty/Surface View/OSSurfaceView.swift | 44 ++- .../Surface View/SurfaceView_AppKit.swift | 36 +-- .../OSSurfaceView+ScreenTextTests.swift | 70 ++++- .../SurfaceView+AccessibilityTests.swift | 39 --- src/apprt/embedded.zig | 15 +- src/terminal/Screen.zig | 294 +++++++++++++++--- 7 files changed, 360 insertions(+), 140 deletions(-) delete mode 100644 macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift diff --git a/include/ghostty.h b/include/ghostty.h index 3dbc866b7..a0933e556 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 { diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 42e8fb7bd..37cfb7f4c 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -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 ) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 1d339d8bd..19b459a25 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -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. diff --git a/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift index 6d16b7e69..b38351ba3 100644 --- a/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift +++ b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift @@ -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)) + } } diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift deleted file mode 100644 index fad35c3f3..000000000 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift +++ /dev/null @@ -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)) - } -} diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6ed1201c8..80e454d98 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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; } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 856d6ee02..4136325dc 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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]); +}