Skip to content

Commit 2469226

Browse files
committed
macos: add selection range to ScreenText
Extend ScreenText to include the active selection's UTF-8 byte offsets alongside the text and viewport. The selection range is computed as part of the same formatter pass as the text, so they're guaranteed to be in sync. accessibilitySelectedTextRange now reads the cached selection range directly, and we now return NSNotFound when there's no selection per the NSAccessibility convention for read-only content.
1 parent 0e6c6bc commit 2469226

7 files changed

Lines changed: 359 additions & 124 deletions

File tree

include/ghostty.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,8 @@ typedef struct {
419419
uintptr_t text_len;
420420
uintptr_t viewport_start;
421421
uintptr_t viewport_end;
422+
uintptr_t selection_start;
423+
uintptr_t selection_end;
422424
} ghostty_screen_text_s;
423425

424426
typedef enum {

macos/Sources/Ghostty/Surface View/OSSurfaceView.swift

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,21 @@ extension Ghostty {
4949
/// True when the surface is in readonly mode.
5050
@Published private(set) var readonly: Bool = false
5151

52-
/// Snapshot of the full screen text with viewport position
53-
/// and line-start offsets precomputed in UTF-16 space (NSRange
54-
/// semantics).
52+
/// Snapshot of the full screen text with viewport position,
53+
/// optional selection range, and line-start offsets precomputed
54+
/// in UTF-16 space (NSRange semantics).
5555
struct ScreenText: Equatable {
5656
static let empty = ScreenText(
5757
text: "",
5858
viewportRange: NSRange(location: 0, length: 0),
59+
selectionRange: nil,
5960
utf16Length: 0,
6061
lineStarts: [0]
6162
)
6263

6364
let text: String
6465
let viewportRange: NSRange
66+
let selectionRange: NSRange?
6567
let utf16Length: Int
6668

6769
/// UTF-16 offsets of the start of each line. Always
@@ -252,22 +254,35 @@ extension Ghostty.OSSurfaceView {
252254

253255
extension Ghostty.OSSurfaceView.ScreenText {
254256
/// Build from a UTF-8 string and the byte offsets that delimit
255-
/// the viewport, translating to UTF-16 / NSRange space.
257+
/// the viewport and (optionally) the selection, translating to
258+
/// UTF-16 / NSRange space. A zero-length selection input means
259+
/// "no selection."
256260
init(
257261
text: String,
258262
viewportStartByte: Int,
259-
viewportEndByte: Int
263+
viewportEndByte: Int,
264+
selectionStartByte: Int = 0,
265+
selectionEndByte: Int = 0
260266
) {
261267
let utf8 = text.utf8
262268
let utf16Length = text.utf16.count
263269

264-
// Convert the viewport from UTF-8 bytes to UTF-16 offsets.
265-
let viewportStart = utf8.index(
266-
utf8.startIndex, offsetBy: viewportStartByte, limitedBy: utf8.endIndex
267-
)?.utf16Offset(in: text) ?? utf16Length
268-
let viewportEnd = max(viewportStart, utf8.index(
269-
utf8.startIndex, offsetBy: viewportEndByte, limitedBy: utf8.endIndex
270-
)?.utf16Offset(in: text) ?? utf16Length)
270+
// Convert a UTF-8 byte range from the Zig side to a UTF-16
271+
// NSRange, clamping out-of-range offsets to end-of-text.
272+
func range(from startByte: Int, to endByte: Int) -> NSRange {
273+
let start = utf8.index(
274+
utf8.startIndex, offsetBy: startByte, limitedBy: utf8.endIndex
275+
)?.utf16Offset(in: text) ?? utf16Length
276+
let end = max(start, utf8.index(
277+
utf8.startIndex, offsetBy: endByte, limitedBy: utf8.endIndex
278+
)?.utf16Offset(in: text) ?? utf16Length)
279+
return NSRange(location: start, length: end - start)
280+
}
281+
282+
let viewportRange = range(from: viewportStartByte, to: viewportEndByte)
283+
let selectionRange: NSRange? = selectionStartByte < selectionEndByte
284+
? range(from: selectionStartByte, to: selectionEndByte)
285+
: nil
271286

272287
// getLineStart's contentsEnd tells us whether the line ended
273288
// with a terminator — including at end-of-buffer, where a
@@ -294,9 +309,8 @@ extension Ghostty.OSSurfaceView.ScreenText {
294309

295310
self.init(
296311
text: text,
297-
viewportRange: NSRange(
298-
location: viewportStart,
299-
length: viewportEnd - viewportStart),
312+
viewportRange: viewportRange,
313+
selectionRange: selectionRange,
300314
utf16Length: utf16Length,
301315
lineStarts: lineStarts
302316
)

macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,9 @@ extension Ghostty {
295295
return .init(
296296
text: String(cString: cString),
297297
viewportStartByte: Int(info.viewport_start),
298-
viewportEndByte: Int(info.viewport_end)
298+
viewportEndByte: Int(info.viewport_end),
299+
selectionStartByte: Int(info.selection_start),
300+
selectionEndByte: Int(info.selection_end)
299301
)
300302
}
301303

@@ -2307,38 +2309,8 @@ extension Ghostty.SurfaceView {
23072309
return cachedScreenText.get().text
23082310
}
23092311

2310-
/// UTF-16 NSRange of the current selection within the cached
2311-
/// screen text, or `NSRange(NSNotFound, 0)` when no selection
2312-
/// exists or the selection text appears more than once.
23132312
override func accessibilitySelectedTextRange() -> NSRange {
2314-
guard let selected = accessibilitySelectedText(), !selected.isEmpty else {
2315-
return NSRange(location: 0, length: 0)
2316-
}
2317-
return Ghostty.SurfaceView.accessibilityRange(
2318-
of: selected, in: cachedScreenText.get().text)
2319-
}
2320-
2321-
/// UTF-16 NSRange of `needle` inside `haystack` when there is
2322-
/// exactly one occurrence. Returns `NSRange(NSNotFound, 0)` for
2323-
/// no match or a multiple-occurrence ambiguous match.
2324-
static func accessibilityRange(of needle: String, in haystack: String) -> NSRange {
2325-
guard !needle.isEmpty else { return NSRange(location: NSNotFound, length: 0) }
2326-
let string = haystack as NSString
2327-
let first = string.range(of: needle)
2328-
guard first.location != NSNotFound else {
2329-
return NSRange(location: NSNotFound, length: 0)
2330-
}
2331-
let searchStart = first.location + first.length
2332-
let rest = NSRange(
2333-
location: searchStart,
2334-
length: string.length - searchStart)
2335-
if rest.length > 0 {
2336-
let second = string.range(of: needle, range: rest)
2337-
if second.location != NSNotFound {
2338-
return NSRange(location: NSNotFound, length: 0)
2339-
}
2340-
}
2341-
return first
2313+
return cachedScreenText.get().selectionRange ?? NSRange(location: NSNotFound, length: 0)
23422314
}
23432315

23442316
/// Returns the currently selected text as a string.

macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import Foundation
33
import Testing
44

55
struct OSSurfaceViewScreenTextTests {
6+
typealias ScreenText = Ghostty.OSSurfaceView.ScreenText
7+
68
@Test func emptyTextProducesEmpty() {
7-
let screenText = Ghostty.OSSurfaceView.ScreenText(
9+
let screenText = ScreenText(
810
text: "",
911
viewportStartByte: 0,
1012
viewportEndByte: 0
@@ -13,7 +15,7 @@ struct OSSurfaceViewScreenTextTests {
1315
}
1416

1517
@Test func asciiOffsetsAreIdentity() {
16-
let screenText = Ghostty.OSSurfaceView.ScreenText(
18+
let screenText = ScreenText(
1719
text: "hello\nworld",
1820
viewportStartByte: 6,
1921
viewportEndByte: 11
@@ -26,7 +28,7 @@ struct OSSurfaceViewScreenTextTests {
2628
// U+1F600 ("😀") is 4 bytes in UTF-8 and a surrogate pair (two
2729
// code units) in UTF-16.
2830
let text = "a😀b"
29-
let screenText = Ghostty.OSSurfaceView.ScreenText(
31+
let screenText = ScreenText(
3032
text: text,
3133
viewportStartByte: 0,
3234
viewportEndByte: text.utf8.count
@@ -38,7 +40,7 @@ struct OSSurfaceViewScreenTextTests {
3840

3941
@Test func viewportRangeSkipsAcrossSurrogatePair() {
4042
let text = "a😀b"
41-
let screenText = Ghostty.OSSurfaceView.ScreenText(
43+
let screenText = ScreenText(
4244
text: text,
4345
viewportStartByte: 5, // byte index of "b"
4446
viewportEndByte: 6 // byte index past "b"
@@ -48,7 +50,7 @@ struct OSSurfaceViewScreenTextTests {
4850

4951
@Test func cjkCharacterCountsAsOneUTF16Unit() {
5052
let text = ""
51-
let screenText = Ghostty.OSSurfaceView.ScreenText(
53+
let screenText = ScreenText(
5254
text: text,
5355
viewportStartByte: 0,
5456
viewportEndByte: text.utf8.count
@@ -58,7 +60,7 @@ struct OSSurfaceViewScreenTextTests {
5860
}
5961

6062
@Test func viewportPastEndClampsToEndOfText() {
61-
let screenText = Ghostty.OSSurfaceView.ScreenText(
63+
let screenText = ScreenText(
6264
text: "hi",
6365
viewportStartByte: 10,
6466
viewportEndByte: 20
@@ -70,7 +72,7 @@ struct OSSurfaceViewScreenTextTests {
7072
@Test func reversedOffsetsCollapseToZeroLength() {
7173
// A negative NSRange.length is meaningless to AX clients; the
7274
// init normalizes to a zero-length range at start.
73-
let screenText = Ghostty.OSSurfaceView.ScreenText(
75+
let screenText = ScreenText(
7476
text: "abcdef",
7577
viewportStartByte: 4,
7678
viewportEndByte: 2
@@ -85,7 +87,7 @@ struct OSSurfaceViewScreenTextTests {
8587
from: text.utf8.startIndex,
8688
to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)!
8789
)
88-
let screenText = Ghostty.OSSurfaceView.ScreenText(
90+
let screenText = ScreenText(
8991
text: text,
9092
viewportStartByte: start,
9193
viewportEndByte: start + viewport.utf8.count
@@ -107,7 +109,7 @@ struct OSSurfaceViewScreenTextTests {
107109
("a\nb\n", [0, 2, 4]),
108110
])
109111
func lineStarts(text: String, expected: [Int]) {
110-
let screenText = Ghostty.OSSurfaceView.ScreenText(
112+
let screenText = ScreenText(
111113
text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count
112114
)
113115
#expect(screenText.lineStarts == expected)
@@ -123,14 +125,14 @@ struct OSSurfaceViewScreenTextTests {
123125
(100, 2) // way past end still clamps
124126
])
125127
func lineAtIndex(index: Int, expected: Int) {
126-
let screenText = Ghostty.OSSurfaceView.ScreenText(
128+
let screenText = ScreenText(
127129
text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5
128130
)
129131
#expect(screenText.line(at: index) == expected)
130132
}
131133

132134
@Test func lineAtNegativeIndexClampsToZero() {
133-
let screenText = Ghostty.OSSurfaceView.ScreenText(
135+
let screenText = ScreenText(
134136
text: "a\nb", viewportStartByte: 0, viewportEndByte: 3
135137
)
136138
#expect(screenText.line(at: -1) == 0)
@@ -139,9 +141,53 @@ struct OSSurfaceViewScreenTextTests {
139141
@Test func lineForEmptyText() {
140142
// Past-end indices clamp to line 0 when the text is empty;
141143
// the in-range `at: 0` case is covered by lineAtIndex.
142-
let screenText = Ghostty.OSSurfaceView.ScreenText(
144+
let screenText = ScreenText(
143145
text: "", viewportStartByte: 0, viewportEndByte: 0
144146
)
145147
#expect(screenText.line(at: 100) == 0)
146148
}
149+
150+
// MARK: selectionRange
151+
152+
@Test(arguments: [
153+
(2, 2), // zero-length (also the default-args case)
154+
(4, 2), // reversed
155+
])
156+
func selectionMustBePositive(startByte: Int, endByte: Int) {
157+
let screenText = ScreenText(
158+
text: "hello",
159+
viewportStartByte: 0, viewportEndByte: 5,
160+
selectionStartByte: startByte, selectionEndByte: endByte
161+
)
162+
#expect(screenText.selectionRange == nil)
163+
}
164+
165+
@Test func selectionAsciiOffsetsAreIdentity() {
166+
let screenText = ScreenText(
167+
text: "hello world",
168+
viewportStartByte: 0, viewportEndByte: 11,
169+
selectionStartByte: 6, selectionEndByte: 11
170+
)
171+
#expect(screenText.selectionRange == NSRange(location: 6, length: 5))
172+
}
173+
174+
@Test func selectionAcrossSurrogatePair() {
175+
let text = "a😀b"
176+
let screenText = ScreenText(
177+
text: text,
178+
viewportStartByte: 0, viewportEndByte: text.utf8.count,
179+
selectionStartByte: 1, selectionEndByte: 5 // "😀"
180+
)
181+
// "😀" occupies a UTF-16 surrogate pair (locations 1..3).
182+
#expect(screenText.selectionRange == NSRange(location: 1, length: 2))
183+
}
184+
185+
@Test func selectionPastEndClampsToEndOfText() {
186+
let screenText = ScreenText(
187+
text: "hi",
188+
viewportStartByte: 0, viewportEndByte: 2,
189+
selectionStartByte: 10, selectionEndByte: 20
190+
)
191+
#expect(screenText.selectionRange == NSRange(location: 2, length: 0))
192+
}
147193
}

macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift

Lines changed: 0 additions & 39 deletions
This file was deleted.

src/apprt/embedded.zig

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,8 @@ pub const CAPI = struct {
13101310
text_len: usize,
13111311
viewport_start: usize,
13121312
viewport_end: usize,
1313+
selection_start: usize,
1314+
selection_end: usize,
13131315

13141316
pub fn deinit(self: *ScreenText) void {
13151317
if (self.text) |ptr| {
@@ -1689,10 +1691,11 @@ pub const CAPI = struct {
16891691
}
16901692

16911693
/// Read the full screen text along with the UTF-8 byte offsets
1692-
/// that delimit the visible viewport, as a self-consistent
1693-
/// snapshot. On success free with ghostty_surface_free_screen_text.
1694-
/// On failure `*result` is zero-initialized so calling
1695-
/// ghostty_surface_free_screen_text on it is a safe no-op.
1694+
/// that delimit the visible viewport and (if `has_selection`) the
1695+
/// active selection, as a self-consistent snapshot. On success
1696+
/// free with ghostty_surface_free_screen_text. On failure `*result`
1697+
/// is zero-initialized so calling ghostty_surface_free_screen_text
1698+
/// on it is a safe no-op.
16961699
export fn ghostty_surface_read_screen(
16971700
surface: *Surface,
16981701
result: *ScreenText,
@@ -1706,6 +1709,8 @@ pub const CAPI = struct {
17061709
.text_len = 0,
17071710
.viewport_start = 0,
17081711
.viewport_end = 0,
1712+
.selection_start = 0,
1713+
.selection_end = 0,
17091714
};
17101715
return false;
17111716
};
@@ -1714,6 +1719,8 @@ pub const CAPI = struct {
17141719
.text_len = screen_text.text.len,
17151720
.viewport_start = screen_text.viewport.start,
17161721
.viewport_end = screen_text.viewport.end,
1722+
.selection_start = if (screen_text.selection) |s| s.start else 0,
1723+
.selection_end = if (screen_text.selection) |s| s.end else 0,
17171724
};
17181725
return true;
17191726
}

0 commit comments

Comments
 (0)