macos: move ScreenText to OSSurfaceView
This allows it to be shared with a future iOS implementation.pull/12881/head
parent
6dc8e23046
commit
eed26686f6
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue