From 6dc8e23046a41c86fcdaeddee499138094ed3cb6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 15:47:45 -0400 Subject: [PATCH 1/3] macos: fix AXVisibleCharacterRange and UTF-16 indexing Several accessibility methods on SurfaceView returned values in the wrong coordinate system, which made the visible-range and selection APIs unusable across screen readers, translation tools, and AI autocomplete apps: - accessibilityVisibleCharacterRange returned the full scrollback as the visible range. AX clients would fetch hundreds of KB to translate a single sentence on screen. - accessibilityNumberOfCharacters reported grapheme count rather than UTF-16 code units. Any range derived from it indexed into the wrong bytes when the buffer contained emoji. - accessibilityLine(for:) treated the parameter as a grapheme index, same problem. - accessibilitySelectedTextRange returned viewport-cell-linear offsets (y*cols+x), which are unrelated to UTF-16 NSRange semantics. The selection's range and text disagreed for non-ASCII content. The main idea is a self-consistent screen-text snapshot. The Zig core adds Screen.screenText which uses ScreenFormatter directly to emit the full screen along with a per-byte Pin array, then binary-searches the array for the viewport's start/end byte offsets. The C API exports this as ghostty_surface_read_screen / ghostty_screen_text_s. The macOS layer caches a Ghostty.SurfaceView.ScreenText snapshot that holds the text, the viewport NSRange in UTF-16, the total UTF-16 length, and a precomputed lineStarts table. The AX overrides read from this single cache so all reported lengths and ranges live in the same coordinate system. accessibilitySelectedTextRange searches the cached text for the selection's bytes and returns the unique match, or NSNotFound when the selection appears more than once. A wrong-but-valid range would mislead AX clients more than NSNotFound. The preexisting cachedScreenContents and cachedVisibleContents stay in place because GetTerminalDetailsIntent still reads them. Folding those into the new cache can be a separate cleanup. The overall result was verified using some small AX scripts to confirm correct AXVisibleCharacterRange, AXNumberOfCharacters, AXStringForRange, and AXSelectedTextRange behavior. Note that AXRangeForPosition and AXBoundsForRange are still not yet implemented. Those need a little more foundational work and can come in follow-up changes. --- include/ghostty.h | 11 + .../Surface View/SurfaceView_AppKit.swift | 170 ++++++++++++++-- .../SurfaceView+AccessibilityTests.swift | 188 ++++++++++++++++++ src/Surface.zig | 11 + src/apprt/embedded.zig | 51 +++++ src/terminal/Screen.zig | 183 ++++++++++++++++- 6 files changed, 585 insertions(+), 29 deletions(-) create mode 100644 macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift 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); + } +} From eed26686f6e85628741d5fa2d5a4a5020ebdc89c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 18:28:40 -0400 Subject: [PATCH 2/3] macos: move ScreenText to OSSurfaceView This allows it to be shared with a future iOS implementation. --- .../Ghostty/Surface View/OSSurfaceView.swift | 92 +++++++++++ .../Surface View/SurfaceView_AppKit.swift | 97 +----------- .../OSSurfaceView+ScreenTextTests.swift | 147 +++++++++++++++++ .../SurfaceView+AccessibilityTests.swift | 149 ------------------ 4 files changed, 241 insertions(+), 244 deletions(-) create mode 100644 macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 2bf9e4cf3..42e8fb7bd 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -49,6 +49,45 @@ 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). + 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 + } + } + /// 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 +218,56 @@ extension Ghostty.OSSurfaceView { return true } } + +extension Ghostty.OSSurfaceView.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 + ) + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 0958e3d0c..1d339d8bd 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -209,46 +209,7 @@ extension Ghostty { // The cached contents of the screen. 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 + private(set) var cachedScreenText: CachedValue /// Event monitor (see individual events for why) private var eventMonitor: Any? @@ -328,7 +289,7 @@ extension Ghostty { } defer { ghostty_surface_free_screen_text(surface, &info) } guard let cString = info.text else { return .empty } - return ScreenText( + return .init( text: String(cString: cString), viewportStartByte: Int(info.viewport_start), viewportEndByte: Int(info.viewport_end) @@ -2312,60 +2273,6 @@ 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 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..6d16b7e69 --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift @@ -0,0 +1,147 @@ +@testable import Ghostty +import Foundation +import Testing + +struct OSSurfaceViewScreenTextTests { + @Test func emptyTextProducesEmpty() { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "", + viewportStartByte: 0, + viewportEndByte: 0 + ) + #expect(screenText == .empty) + } + + @Test func asciiOffsetsAreIdentity() { + let screenText = Ghostty.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.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.OSSurfaceView.ScreenText( + text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5 + ) + #expect(screenText.line(at: index) == expected) + } + + @Test func lineAtNegativeIndexClampsToZero() { + let screenText = Ghostty.OSSurfaceView.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.OSSurfaceView.ScreenText( + text: "", viewportStartByte: 0, viewportEndByte: 0 + ) + #expect(screenText.line(at: 100) == 0) + } +} diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift index 6a12db8be..fad35c3f3 100644 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift @@ -2,156 +2,7 @@ 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", From 359d3d35f9a5b852a13ce924df93101e3d3869f2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 21:09:04 -0400 Subject: [PATCH 3/3] 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]); +}