macos: move ScreenText to OSSurfaceView

This allows it to be shared with a future iOS implementation.
pull/12881/head
Jon Parise 2026-05-31 18:28:40 -04:00
parent 6dc8e23046
commit eed26686f6
4 changed files with 241 additions and 244 deletions

View File

@ -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
)
}
}

View File

@ -209,46 +209,7 @@ extension Ghostty {
// The cached contents of the screen.
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>
private(set) var cachedScreenText: CachedValue<OSSurfaceView.ScreenText>
/// 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

View File

@ -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)
}
}

View File

@ -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",