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.pull/12881/head
parent
16f2fdc90c
commit
6dc8e23046
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -210,6 +210,46 @@ extension Ghostty {
|
|||
private(set) var cachedScreenContents: CachedValue<String>
|
||||
private(set) var cachedVisibleContents: CachedValue<String>
|
||||
|
||||
/// 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<ScreenText>
|
||||
|
||||
/// 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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue