Merge 359d3d35f9 into bfe633a948
commit
8049c6a0bb
|
|
@ -414,6 +414,15 @@ typedef struct {
|
|||
uintptr_t text_len;
|
||||
} ghostty_text_s;
|
||||
|
||||
typedef struct {
|
||||
const char* text;
|
||||
uintptr_t text_len;
|
||||
uintptr_t viewport_start;
|
||||
uintptr_t viewport_end;
|
||||
uintptr_t selection_start;
|
||||
uintptr_t selection_end;
|
||||
} ghostty_screen_text_s;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_POINT_ACTIVE,
|
||||
GHOSTTY_POINT_VIEWPORT,
|
||||
|
|
@ -1161,6 +1170,10 @@ GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
|
|||
ghostty_selection_s,
|
||||
ghostty_text_s*);
|
||||
GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
|
||||
GHOSTTY_API bool ghostty_surface_read_screen(ghostty_surface_t,
|
||||
ghostty_screen_text_s*);
|
||||
GHOSTTY_API void ghostty_surface_free_screen_text(ghostty_surface_t,
|
||||
ghostty_screen_text_s*);
|
||||
|
||||
#ifdef __APPLE__
|
||||
GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,47 @@ extension Ghostty {
|
|||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
/// Snapshot of the full screen text with viewport position,
|
||||
/// optional selection range, and line-start offsets precomputed
|
||||
/// in UTF-16 space (NSRange semantics).
|
||||
struct ScreenText: Equatable {
|
||||
static let empty = ScreenText(
|
||||
text: "",
|
||||
viewportRange: NSRange(location: 0, length: 0),
|
||||
selectionRange: nil,
|
||||
utf16Length: 0,
|
||||
lineStarts: [0]
|
||||
)
|
||||
|
||||
let text: String
|
||||
let viewportRange: NSRange
|
||||
let selectionRange: NSRange?
|
||||
let utf16Length: Int
|
||||
|
||||
/// UTF-16 offsets of the start of each line. Always
|
||||
/// non-empty: index 0 is 0 even for an empty string.
|
||||
let lineStarts: [Int]
|
||||
|
||||
/// Line number (0-based) containing the given UTF-16
|
||||
/// offset. Out-of-range offsets clamp to the nearest
|
||||
/// valid position.
|
||||
func line(at index: Int) -> Int {
|
||||
let clamped = max(0, min(index, utf16Length))
|
||||
// Upper-bound search over lineStarts.
|
||||
var lo = 0
|
||||
var hi = lineStarts.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
if lineStarts[mid] <= clamped {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
|
||||
@Published private(set) var highlighted: Bool = false
|
||||
|
||||
|
|
@ -179,3 +220,68 @@ extension Ghostty.OSSurfaceView {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension Ghostty.OSSurfaceView.ScreenText {
|
||||
/// Build from a UTF-8 string and the byte offsets that delimit
|
||||
/// the viewport and (optionally) the selection, translating to
|
||||
/// UTF-16 / NSRange space. A zero-length selection input means
|
||||
/// "no selection."
|
||||
init(
|
||||
text: String,
|
||||
viewportStartByte: Int,
|
||||
viewportEndByte: Int,
|
||||
selectionStartByte: Int = 0,
|
||||
selectionEndByte: Int = 0
|
||||
) {
|
||||
let utf8 = text.utf8
|
||||
let utf16Length = text.utf16.count
|
||||
|
||||
// Convert a UTF-8 byte range from the Zig side to a UTF-16
|
||||
// NSRange, clamping out-of-range offsets to end-of-text.
|
||||
func range(from startByte: Int, to endByte: Int) -> NSRange {
|
||||
let start = utf8.index(
|
||||
utf8.startIndex, offsetBy: startByte, limitedBy: utf8.endIndex
|
||||
)?.utf16Offset(in: text) ?? utf16Length
|
||||
let end = max(start, utf8.index(
|
||||
utf8.startIndex, offsetBy: endByte, limitedBy: utf8.endIndex
|
||||
)?.utf16Offset(in: text) ?? utf16Length)
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
let viewportRange = range(from: viewportStartByte, to: viewportEndByte)
|
||||
let selectionRange: NSRange? = selectionStartByte < selectionEndByte
|
||||
? range(from: selectionStartByte, to: selectionEndByte)
|
||||
: nil
|
||||
|
||||
// getLineStart's contentsEnd tells us whether the line ended
|
||||
// with a terminator — including at end-of-buffer, where a
|
||||
// trailing terminator means an extra empty line AX clients can
|
||||
// navigate to. Every Unicode line terminator (LF, CR, CRLF,
|
||||
// NEL, LS, PS) counts.
|
||||
let string = text as NSString
|
||||
var lineStarts: [Int] = [0]
|
||||
var cursor = 0
|
||||
while cursor < utf16Length {
|
||||
var start = 0
|
||||
var end = 0
|
||||
var contentsEnd = 0
|
||||
string.getLineStart(
|
||||
&start,
|
||||
end: &end,
|
||||
contentsEnd: &contentsEnd,
|
||||
for: NSRange(location: cursor, length: 0)
|
||||
)
|
||||
if end <= cursor { break }
|
||||
if contentsEnd < end { lineStarts.append(end) }
|
||||
cursor = end
|
||||
}
|
||||
|
||||
self.init(
|
||||
text: text,
|
||||
viewportRange: viewportRange,
|
||||
selectionRange: selectionRange,
|
||||
utf16Length: utf16Length,
|
||||
lineStarts: lineStarts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ extension Ghostty {
|
|||
// The cached contents of the screen.
|
||||
private(set) var cachedScreenContents: CachedValue<String>
|
||||
private(set) var cachedVisibleContents: CachedValue<String>
|
||||
private(set) var cachedScreenText: CachedValue<OSSurfaceView.ScreenText>
|
||||
|
||||
/// Event monitor (see individual events for why)
|
||||
private var eventMonitor: Any?
|
||||
|
|
@ -231,6 +232,7 @@ extension Ghostty {
|
|||
// fix at some point.
|
||||
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
|
||||
self.cachedVisibleContents = self.cachedScreenContents
|
||||
self.cachedScreenText = .init(duration: .milliseconds(500)) { .empty }
|
||||
|
||||
// Initialize with some default frame size. The important thing is that this
|
||||
// is non-zero so that our layer bounds are non-zero so that our renderer
|
||||
|
|
@ -278,6 +280,23 @@ extension Ghostty {
|
|||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
return String(cString: text.text)
|
||||
}
|
||||
cachedScreenText = .init(duration: .milliseconds(500)) { [weak self] in
|
||||
guard let self else { return .empty }
|
||||
guard let surface = self.surface else { return .empty }
|
||||
var info = ghostty_screen_text_s()
|
||||
guard ghostty_surface_read_screen(surface, &info) else {
|
||||
return .empty
|
||||
}
|
||||
defer { ghostty_surface_free_screen_text(surface, &info) }
|
||||
guard let cString = info.text else { return .empty }
|
||||
return .init(
|
||||
text: String(cString: cString),
|
||||
viewportStartByte: Int(info.viewport_start),
|
||||
viewportEndByte: Int(info.viewport_end),
|
||||
selectionStartByte: Int(info.selection_start),
|
||||
selectionEndByte: Int(info.selection_end)
|
||||
)
|
||||
}
|
||||
|
||||
// Set a timer to show the ghost emoji after 500ms if no title is set
|
||||
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
|
|
@ -2277,14 +2296,11 @@ extension Ghostty.SurfaceView {
|
|||
}
|
||||
|
||||
override func accessibilityValue() -> Any? {
|
||||
return cachedScreenContents.get()
|
||||
return cachedScreenText.get().text
|
||||
}
|
||||
|
||||
/// Returns the range of text that is currently selected in the terminal.
|
||||
/// This allows VoiceOver and other assistive technologies to understand
|
||||
/// what text the user has selected.
|
||||
override func accessibilitySelectedTextRange() -> NSRange {
|
||||
return selectedRange()
|
||||
return cachedScreenText.get().selectionRange ?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
/// Returns the currently selected text as a string.
|
||||
|
|
@ -2301,32 +2317,23 @@ extension Ghostty.SurfaceView {
|
|||
return str.isEmpty ? nil : str
|
||||
}
|
||||
|
||||
/// Returns the number of characters in the terminal content.
|
||||
/// This helps assistive technologies understand the size of the content.
|
||||
override func accessibilityNumberOfCharacters() -> Int {
|
||||
let content = cachedScreenContents.get()
|
||||
return content.count
|
||||
return cachedScreenText.get().utf16Length
|
||||
}
|
||||
|
||||
/// Returns the visible character range for the terminal.
|
||||
/// For terminals, we typically show all content as visible.
|
||||
override func accessibilityVisibleCharacterRange() -> NSRange {
|
||||
let content = cachedScreenContents.get()
|
||||
return NSRange(location: 0, length: content.count)
|
||||
return cachedScreenText.get().viewportRange
|
||||
}
|
||||
|
||||
/// Returns the line number for a given character index.
|
||||
/// This helps assistive technologies navigate by line.
|
||||
/// Logical/paragraph-line semantics matching `NSTextView`: lines are
|
||||
/// delimited by hard newlines and soft-wrap is invisible to line
|
||||
/// navigation (a soft-wrapped line is one line).
|
||||
override func accessibilityLine(for index: Int) -> Int {
|
||||
let content = cachedScreenContents.get()
|
||||
let substring = String(content.prefix(index))
|
||||
return substring.components(separatedBy: .newlines).count - 1
|
||||
return cachedScreenText.get().line(at: index)
|
||||
}
|
||||
|
||||
/// Returns a substring for the given range.
|
||||
/// This allows assistive technologies to read specific portions of the content.
|
||||
override func accessibilityString(for range: NSRange) -> String? {
|
||||
let content = cachedScreenContents.get()
|
||||
let content = cachedScreenText.get().text
|
||||
guard let swiftRange = Range(range, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
@testable import Ghostty
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct OSSurfaceViewScreenTextTests {
|
||||
typealias ScreenText = Ghostty.OSSurfaceView.ScreenText
|
||||
|
||||
@Test func emptyTextProducesEmpty() {
|
||||
let screenText = ScreenText(
|
||||
text: "",
|
||||
viewportStartByte: 0,
|
||||
viewportEndByte: 0
|
||||
)
|
||||
#expect(screenText == .empty)
|
||||
}
|
||||
|
||||
@Test func asciiOffsetsAreIdentity() {
|
||||
let screenText = ScreenText(
|
||||
text: "hello\nworld",
|
||||
viewportStartByte: 6,
|
||||
viewportEndByte: 11
|
||||
)
|
||||
#expect(screenText.utf16Length == 11)
|
||||
#expect(screenText.viewportRange == NSRange(location: 6, length: 5))
|
||||
}
|
||||
|
||||
@Test func emojiCountsAsTwoUTF16Units() {
|
||||
// U+1F600 ("😀") is 4 bytes in UTF-8 and a surrogate pair (two
|
||||
// code units) in UTF-16.
|
||||
let text = "a😀b"
|
||||
let screenText = ScreenText(
|
||||
text: text,
|
||||
viewportStartByte: 0,
|
||||
viewportEndByte: text.utf8.count
|
||||
)
|
||||
#expect(text.count == 3)
|
||||
#expect(screenText.utf16Length == 4)
|
||||
#expect(screenText.viewportRange == NSRange(location: 0, length: 4))
|
||||
}
|
||||
|
||||
@Test func viewportRangeSkipsAcrossSurrogatePair() {
|
||||
let text = "a😀b"
|
||||
let screenText = ScreenText(
|
||||
text: text,
|
||||
viewportStartByte: 5, // byte index of "b"
|
||||
viewportEndByte: 6 // byte index past "b"
|
||||
)
|
||||
#expect(screenText.viewportRange == NSRange(location: 3, length: 1))
|
||||
}
|
||||
|
||||
@Test func cjkCharacterCountsAsOneUTF16Unit() {
|
||||
let text = "好"
|
||||
let screenText = ScreenText(
|
||||
text: text,
|
||||
viewportStartByte: 0,
|
||||
viewportEndByte: text.utf8.count
|
||||
)
|
||||
#expect(screenText.utf16Length == 1)
|
||||
#expect(screenText.viewportRange == NSRange(location: 0, length: 1))
|
||||
}
|
||||
|
||||
@Test func viewportPastEndClampsToEndOfText() {
|
||||
let screenText = ScreenText(
|
||||
text: "hi",
|
||||
viewportStartByte: 10,
|
||||
viewportEndByte: 20
|
||||
)
|
||||
#expect(screenText.viewportRange.location == 2)
|
||||
#expect(screenText.viewportRange.length == 0)
|
||||
}
|
||||
|
||||
@Test func reversedOffsetsCollapseToZeroLength() {
|
||||
// A negative NSRange.length is meaningless to AX clients; the
|
||||
// init normalizes to a zero-length range at start.
|
||||
let screenText = ScreenText(
|
||||
text: "abcdef",
|
||||
viewportStartByte: 4,
|
||||
viewportEndByte: 2
|
||||
)
|
||||
#expect(screenText.viewportRange.length == 0)
|
||||
}
|
||||
|
||||
@Test func viewportInMiddleOfPureAscii() {
|
||||
let text = "scrollback\nviewport line\nmore content"
|
||||
let viewport = "viewport line\n"
|
||||
let start = text.utf8.distance(
|
||||
from: text.utf8.startIndex,
|
||||
to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)!
|
||||
)
|
||||
let screenText = ScreenText(
|
||||
text: text,
|
||||
viewportStartByte: start,
|
||||
viewportEndByte: start + viewport.utf8.count
|
||||
)
|
||||
#expect(screenText.utf16Length == (text as NSString).length)
|
||||
#expect(screenText.viewportRange.length == (viewport as NSString).length)
|
||||
}
|
||||
|
||||
// MARK: lineStarts
|
||||
|
||||
@Test(arguments: [
|
||||
// Empty-text case is covered by emptyTextProducesEmpty.
|
||||
("hello", [0]),
|
||||
("a\nb\nc", [0, 2, 4]),
|
||||
// CRLF is a single line terminator, not two.
|
||||
("a\r\nb", [0, 3]),
|
||||
// Trailing newline creates an empty trailing line so the
|
||||
// past-end cursor is reported on its own line.
|
||||
("a\nb\n", [0, 2, 4]),
|
||||
])
|
||||
func lineStarts(text: String, expected: [Int]) {
|
||||
let screenText = ScreenText(
|
||||
text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count
|
||||
)
|
||||
#expect(screenText.lineStarts == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(0, 0), // before first char
|
||||
(1, 0), // on the '\n'
|
||||
(2, 1), // start of second line
|
||||
(3, 1), // inside second line
|
||||
(4, 2), // start of third line
|
||||
(5, 2), // past end clamps to last line
|
||||
(100, 2) // way past end still clamps
|
||||
])
|
||||
func lineAtIndex(index: Int, expected: Int) {
|
||||
let screenText = ScreenText(
|
||||
text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5
|
||||
)
|
||||
#expect(screenText.line(at: index) == expected)
|
||||
}
|
||||
|
||||
@Test func lineAtNegativeIndexClampsToZero() {
|
||||
let screenText = ScreenText(
|
||||
text: "a\nb", viewportStartByte: 0, viewportEndByte: 3
|
||||
)
|
||||
#expect(screenText.line(at: -1) == 0)
|
||||
}
|
||||
|
||||
@Test func lineForEmptyText() {
|
||||
// Past-end indices clamp to line 0 when the text is empty;
|
||||
// the in-range `at: 0` case is covered by lineAtIndex.
|
||||
let screenText = ScreenText(
|
||||
text: "", viewportStartByte: 0, viewportEndByte: 0
|
||||
)
|
||||
#expect(screenText.line(at: 100) == 0)
|
||||
}
|
||||
|
||||
// MARK: selectionRange
|
||||
|
||||
@Test(arguments: [
|
||||
(2, 2), // zero-length (also the default-args case)
|
||||
(4, 2), // reversed
|
||||
])
|
||||
func selectionMustBePositive(startByte: Int, endByte: Int) {
|
||||
let screenText = ScreenText(
|
||||
text: "hello",
|
||||
viewportStartByte: 0, viewportEndByte: 5,
|
||||
selectionStartByte: startByte, selectionEndByte: endByte
|
||||
)
|
||||
#expect(screenText.selectionRange == nil)
|
||||
}
|
||||
|
||||
@Test func selectionAsciiOffsetsAreIdentity() {
|
||||
let screenText = ScreenText(
|
||||
text: "hello world",
|
||||
viewportStartByte: 0, viewportEndByte: 11,
|
||||
selectionStartByte: 6, selectionEndByte: 11
|
||||
)
|
||||
#expect(screenText.selectionRange == NSRange(location: 6, length: 5))
|
||||
}
|
||||
|
||||
@Test func selectionAcrossSurrogatePair() {
|
||||
let text = "a😀b"
|
||||
let screenText = ScreenText(
|
||||
text: text,
|
||||
viewportStartByte: 0, viewportEndByte: text.utf8.count,
|
||||
selectionStartByte: 1, selectionEndByte: 5 // "😀"
|
||||
)
|
||||
// "😀" occupies a UTF-16 surrogate pair (locations 1..3).
|
||||
#expect(screenText.selectionRange == NSRange(location: 1, length: 2))
|
||||
}
|
||||
|
||||
@Test func selectionPastEndClampsToEndOfText() {
|
||||
let screenText = ScreenText(
|
||||
text: "hi",
|
||||
viewportStartByte: 0, viewportEndByte: 2,
|
||||
selectionStartByte: 10, selectionEndByte: 20
|
||||
)
|
||||
#expect(screenText.selectionRange == NSRange(location: 2, length: 0))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,22 @@ pub const CAPI = struct {
|
|||
}
|
||||
};
|
||||
|
||||
// ghostty_screen_text_s
|
||||
const ScreenText = extern struct {
|
||||
text: ?[*:0]const u8,
|
||||
text_len: usize,
|
||||
viewport_start: usize,
|
||||
viewport_end: usize,
|
||||
selection_start: usize,
|
||||
selection_end: usize,
|
||||
|
||||
pub fn deinit(self: *ScreenText) void {
|
||||
if (self.text) |ptr| {
|
||||
global.alloc.free(ptr[0..self.text_len :0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ghostty_point_s
|
||||
const Point = extern struct {
|
||||
tag: Tag,
|
||||
|
|
@ -1681,6 +1697,48 @@ pub const CAPI = struct {
|
|||
ptr.deinit();
|
||||
}
|
||||
|
||||
/// Read the full screen text along with the UTF-8 byte offsets
|
||||
/// that delimit the visible viewport and (if `has_selection`) the
|
||||
/// active selection, as a self-consistent snapshot. On success
|
||||
/// free with ghostty_surface_free_screen_text. On failure `*result`
|
||||
/// is zero-initialized so calling ghostty_surface_free_screen_text
|
||||
/// on it is a safe no-op.
|
||||
export fn ghostty_surface_read_screen(
|
||||
surface: *Surface,
|
||||
result: *ScreenText,
|
||||
) bool {
|
||||
const screen_text = surface.core_surface.screenText(
|
||||
global.alloc,
|
||||
) catch |err| {
|
||||
log.warn("error reading screen text err={}", .{err});
|
||||
result.* = .{
|
||||
.text = null,
|
||||
.text_len = 0,
|
||||
.viewport_start = 0,
|
||||
.viewport_end = 0,
|
||||
.selection_start = 0,
|
||||
.selection_end = 0,
|
||||
};
|
||||
return false;
|
||||
};
|
||||
result.* = .{
|
||||
.text = screen_text.text.ptr,
|
||||
.text_len = screen_text.text.len,
|
||||
.viewport_start = screen_text.viewport.start,
|
||||
.viewport_end = screen_text.viewport.end,
|
||||
.selection_start = if (screen_text.selection) |s| s.start else 0,
|
||||
.selection_end = if (screen_text.selection) |s| s.end else 0,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
export fn ghostty_surface_free_screen_text(
|
||||
_: *Surface,
|
||||
ptr: *ScreenText,
|
||||
) void {
|
||||
ptr.deinit();
|
||||
}
|
||||
|
||||
/// Tell the surface that it needs to schedule a render
|
||||
export fn ghostty_surface_refresh(surface: *Surface) void {
|
||||
surface.refresh();
|
||||
|
|
|
|||
|
|
@ -2511,6 +2511,100 @@ pub fn selectionString(
|
|||
return text;
|
||||
}
|
||||
|
||||
/// Full screen text (scrollback + active) with UTF-8 byte ranges
|
||||
/// delimiting the visible viewport and (if any) the active selection.
|
||||
pub const ScreenText = struct {
|
||||
text: [:0]const u8,
|
||||
viewport: Range,
|
||||
selection: ?Range,
|
||||
|
||||
pub const Range = struct {
|
||||
start: usize,
|
||||
end: usize,
|
||||
};
|
||||
|
||||
/// Slice of `text` containing the visible bytes
|
||||
pub fn visible(self: ScreenText) []const u8 {
|
||||
return self.text[self.viewport.start..self.viewport.end];
|
||||
}
|
||||
|
||||
pub fn deinit(self: ScreenText, alloc: Allocator) void {
|
||||
alloc.free(self.text);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn screenText(
|
||||
self: *Screen,
|
||||
alloc: Allocator,
|
||||
) Allocator.Error!ScreenText {
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
var pins: std.ArrayList(Pin) = .empty;
|
||||
defer pins.deinit(alloc);
|
||||
|
||||
var formatter: ScreenFormatter = .init(self, .{
|
||||
.emit = .plain,
|
||||
.unwrap = true,
|
||||
.trim = false,
|
||||
});
|
||||
formatter.pin_map = .{ .alloc = alloc, .map = &pins };
|
||||
formatter.format(&aw.writer) catch return error.OutOfMemory;
|
||||
|
||||
const text = try aw.toOwnedSliceSentinel(0);
|
||||
errdefer alloc.free(text);
|
||||
|
||||
// The formatter's contract: one Pin per byte written.
|
||||
assert(pins.items.len == text.len);
|
||||
|
||||
// pins.items is monotonic in Pin order (the formatter emits in
|
||||
// document order). Pin.before is O(pages) for cross-node
|
||||
// comparisons, so we use binary search rather than a linear scan.
|
||||
const range = struct {
|
||||
fn locate(items: []const Pin, tl: Pin, br: Pin) ScreenText.Range {
|
||||
const start = std.sort.partitionPoint(Pin, items, tl, struct {
|
||||
fn pred(ctx: Pin, pin: Pin) bool {
|
||||
return pin.before(ctx);
|
||||
}
|
||||
}.pred);
|
||||
const end = start + std.sort.partitionPoint(
|
||||
Pin,
|
||||
items[start..],
|
||||
br,
|
||||
struct {
|
||||
fn pred(ctx: Pin, pin: Pin) bool {
|
||||
return !ctx.before(pin);
|
||||
}
|
||||
}.pred,
|
||||
);
|
||||
// Preserve `start <= end` when the range sits past end-of-text.
|
||||
return .{ .start = @min(start, end), .end = end };
|
||||
}
|
||||
}.locate;
|
||||
|
||||
const viewport = range(
|
||||
pins.items,
|
||||
self.pages.getTopLeft(.viewport),
|
||||
self.pages.getBottomRight(.viewport).?,
|
||||
);
|
||||
|
||||
const selection: ?ScreenText.Range = if (self.selection) |sel| sel: {
|
||||
var r = range(pins.items, sel.topLeft(self), sel.bottomRight(self));
|
||||
// The partition can include row-terminating newlines whose Pins
|
||||
// the formatter assigned to satisfy empty lines in the text layout.
|
||||
// Trim to match `selectionString`. This is safe because cell bytes
|
||||
// are never '\n'.
|
||||
while (r.end > r.start and text[r.end - 1] == '\n') r.end -= 1;
|
||||
break :sel r;
|
||||
} else null;
|
||||
|
||||
return .{
|
||||
.text = text,
|
||||
.viewport = viewport,
|
||||
.selection = selection,
|
||||
};
|
||||
}
|
||||
|
||||
pub const SelectLine = struct {
|
||||
/// The pin of some part of the line to select.
|
||||
pin: Pin,
|
||||
|
|
@ -10508,3 +10602,294 @@ test "Screen: promptClickMove click right of input cursor on last char" {
|
|||
try testing.expectEqual(@as(usize, 1), result.right);
|
||||
try testing.expectEqual(@as(usize, 0), result.left);
|
||||
}
|
||||
|
||||
test "Screen: screenText viewport at bottom" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST", result.text);
|
||||
try testing.expectEqualStrings("3IJKL\n4MNOP\n5QRST", result.visible());
|
||||
}
|
||||
|
||||
test "Screen: screenText viewport scrolled to top" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
s.scroll(.{ .top = {} });
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
// The trailing newline of the last viewport row is mapped to the
|
||||
// viewport row, not to the row after, so it sits inside the range.
|
||||
try testing.expectEqual(@as(usize, 0), result.viewport.start);
|
||||
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n", result.visible());
|
||||
}
|
||||
|
||||
test "Screen: screenText viewport scrolled mid-buffer" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
s.scroll(.{ .delta_row = -1 });
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
try testing.expectEqualStrings("2EFGH\n3IJKL\n4MNOP\n", result.visible());
|
||||
}
|
||||
|
||||
test "Screen: screenText empty screen" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
try testing.expectEqualStrings("", result.text);
|
||||
try testing.expectEqual(@as(usize, 0), result.viewport.start);
|
||||
try testing.expectEqual(@as(usize, 0), result.viewport.end);
|
||||
}
|
||||
|
||||
test "Screen: screenText wide character at viewport boundary" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n好kAB\n4MNOP\n5QRST");
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
// Viewport offsets must land on UTF-8 codepoint boundaries.
|
||||
try testing.expect(result.text[result.viewport.start] & 0xC0 != 0x80);
|
||||
if (result.viewport.end < result.text.len) {
|
||||
try testing.expect(result.text[result.viewport.end] & 0xC0 != 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen: screenText no selection" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
try testing.expect(result.selection == null);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection inside viewport" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
|
||||
|
||||
// Select "EFGH" (row 1, full content). End-of-row selections must
|
||||
// not pull in the trailing newline.
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqualStrings("EFGH", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection mid-row" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
|
||||
|
||||
// Select "EFG" — ends mid-row, no trailing newline to trim.
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqualStrings("EFG", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection spanning multiple rows" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
|
||||
|
||||
// Inter-row newlines are preserved; only the selection's trailing
|
||||
// line terminator gets trimmed.
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqualStrings("ABCD\n2EFGH", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection ending in empty row" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
// Row 1 is intentionally empty.
|
||||
try s.testWriteString("1ABCD\n\n3IJKL");
|
||||
|
||||
// Select from row 0 col 0 ("1ABCD") through row 1 col 0 (the empty
|
||||
// row). The empty row contributes no visible cells, so the result
|
||||
// matches `selectionString` which emits "1ABCD" (no trailing rows).
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqualStrings("1ABCD", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection of only blank rows" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
// Rows 1 and 2 are intentionally empty.
|
||||
try s.testWriteString("1ABCD\n\n\n4MNOP");
|
||||
|
||||
// Select only the two blank rows. The trim loop strips every
|
||||
// trailing '\n', collapsing the range to zero length. The C API
|
||||
// reports start == end as "no selection".
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 4, .y = 2 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqual(sel.start, sel.end);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection matches selectionString" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("line one\n\nline three");
|
||||
|
||||
// Select "line one" + the empty row's first cell.
|
||||
const sel = Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
||||
false,
|
||||
);
|
||||
try s.select(sel);
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel_str = try s.selectionString(alloc, .{ .sel = sel, .trim = false });
|
||||
defer alloc.free(sel_str);
|
||||
|
||||
const r = result.selection.?;
|
||||
try testing.expectEqualStrings(sel_str, result.text[r.start..r.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection in scrollback" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
|
||||
|
||||
// Active rows are "3IJKL", "4MNOP", "5QRST"; "1ABCD" and "2EFGH"
|
||||
// are in scrollback. Select "2EFGH" via screen-space pinning to
|
||||
// confirm offsets index into the full snapshot.
|
||||
const start = s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?;
|
||||
const end = s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?;
|
||||
try s.select(Selection.init(start, end, false));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
try testing.expectEqualStrings("2EFGH", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
||||
test "Screen: screenText selection with wide character" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 100 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("好kAB");
|
||||
|
||||
// Select the entire row: "好" (cols 0,1, wide), "k" (col 2),
|
||||
// "A" (col 3), "B" (col 4, end-of-row).
|
||||
try s.select(Selection.init(
|
||||
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
|
||||
s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?,
|
||||
false,
|
||||
));
|
||||
|
||||
const result = try s.screenText(alloc);
|
||||
defer result.deinit(alloc);
|
||||
|
||||
const sel = result.selection.?;
|
||||
// Selection offsets land on UTF-8 codepoint boundaries.
|
||||
try testing.expect(result.text[sel.start] & 0xC0 != 0x80);
|
||||
if (sel.end < result.text.len) {
|
||||
try testing.expect(result.text[sel.end] & 0xC0 != 0x80);
|
||||
}
|
||||
try testing.expectEqualStrings("好kAB", result.text[sel.start..sel.end]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue