Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
106 changes: 106 additions & 0 deletions macos/Sources/Ghostty/Surface View/OSSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
)
}
}
49 changes: 28 additions & 21 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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])
}
Expand Down
Loading
Loading