mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
macOS: Basic Read-Only Accessibility Integration (#7601)
This integrates with macOS accessibility APIs to expose Ghostty terminal structure and content. This is a very, very bare implementation and the terminal contents currently reported are the _full screen and scrollback_ which is way too much for realistic human accessibility use. The target use case for this PR is to enable automated tooling (namely, AI screen readers). However, this is all groundwork we'll need to iterate and improve the accessibility work anyways. To make this work, I also replatformed some of our hacky C APIs onto a more robust `ghostty_surface_read_text` API that can now read arbitrary ranges of the screen into C strings for consumers to use. This will be useful in more places going forward (hint hint). ## Before Accessibility tooling can't read anything, Ghostty has no attributes, no contents, just shows up as a square.  ## After A lot of metadata, including the screen contents as text.  Also, split hierarchies are navigable: https://github.com/user-attachments/assets/a7b2ffb7-dbeb-41b2-8705-9c3200812c4d
This commit is contained in:
@@ -355,6 +355,34 @@ typedef struct {
|
||||
double tl_px_y;
|
||||
uint32_t offset_start;
|
||||
uint32_t offset_len;
|
||||
const char* text;
|
||||
uintptr_t text_len;
|
||||
} ghostty_text_s;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_POINT_ACTIVE,
|
||||
GHOSTTY_POINT_VIEWPORT,
|
||||
GHOSTTY_POINT_SCREEN,
|
||||
GHOSTTY_POINT_SURFACE,
|
||||
} ghostty_point_tag_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_POINT_COORD_EXACT,
|
||||
GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
} ghostty_point_coord_e;
|
||||
|
||||
typedef struct {
|
||||
ghostty_point_tag_e tag;
|
||||
ghostty_point_coord_e coord;
|
||||
uint32_t x;
|
||||
uint32_t y;
|
||||
} ghostty_point_s;
|
||||
|
||||
typedef struct {
|
||||
ghostty_point_s top_left;
|
||||
ghostty_point_s bottom_right;
|
||||
bool rectangle;
|
||||
} ghostty_selection_s;
|
||||
|
||||
typedef struct {
|
||||
@@ -832,16 +860,16 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
|
||||
void*,
|
||||
bool);
|
||||
bool ghostty_surface_has_selection(ghostty_surface_t);
|
||||
uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t);
|
||||
bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
|
||||
bool ghostty_surface_read_text(ghostty_surface_t,
|
||||
ghostty_selection_s,
|
||||
ghostty_text_s*);
|
||||
void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
|
||||
|
||||
#ifdef __APPLE__
|
||||
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
|
||||
void* ghostty_surface_quicklook_font(ghostty_surface_t);
|
||||
uintptr_t ghostty_surface_quicklook_word(ghostty_surface_t,
|
||||
char*,
|
||||
uintptr_t,
|
||||
ghostty_selection_s*);
|
||||
bool ghostty_surface_selection_info(ghostty_surface_t, ghostty_selection_s*);
|
||||
bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
|
||||
#endif
|
||||
|
||||
ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
|
||||
|
@@ -7,6 +7,7 @@ extension SplitView {
|
||||
let visibleSize: CGFloat
|
||||
let invisibleSize: CGFloat
|
||||
let color: Color
|
||||
@Binding var split: CGFloat
|
||||
|
||||
private var visibleWidth: CGFloat? {
|
||||
switch (direction) {
|
||||
@@ -79,6 +80,40 @@ extension SplitView {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(axLabel)
|
||||
.accessibilityValue("\(Int(split * 100))%")
|
||||
.accessibilityHint(axHint)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityAdjustableAction { direction in
|
||||
let adjustment: CGFloat = 0.025
|
||||
switch direction {
|
||||
case .increment:
|
||||
split = min(split + adjustment, 0.9)
|
||||
case .decrement:
|
||||
split = max(split - adjustment, 0.1)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var axLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Horizontal split divider"
|
||||
case .vertical:
|
||||
return "Vertical split divider"
|
||||
}
|
||||
}
|
||||
|
||||
private var axHint: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Drag to resize the left and right panes"
|
||||
case .vertical:
|
||||
return "Drag to resize the top and bottom panes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -42,16 +42,23 @@ struct SplitView<L: View, R: View>: View {
|
||||
left
|
||||
.frame(width: leftRect.size.width, height: leftRect.size.height)
|
||||
.offset(x: leftRect.origin.x, y: leftRect.origin.y)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(leftPaneLabel)
|
||||
right
|
||||
.frame(width: rightRect.size.width, height: rightRect.size.height)
|
||||
.offset(x: rightRect.origin.x, y: rightRect.origin.y)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(rightPaneLabel)
|
||||
Divider(direction: direction,
|
||||
visibleSize: splitterVisibleSize,
|
||||
invisibleSize: splitterInvisibleSize,
|
||||
color: dividerColor)
|
||||
color: dividerColor,
|
||||
split: $split)
|
||||
.position(splitterPoint)
|
||||
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(splitViewLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +144,35 @@ struct SplitView<L: View, R: View>: View {
|
||||
return CGPoint(x: size.width / 2, y: leftRect.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
private var splitViewLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Horizontal split view"
|
||||
case .vertical:
|
||||
return "Vertical split view"
|
||||
}
|
||||
}
|
||||
|
||||
private var leftPaneLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Left pane"
|
||||
case .vertical:
|
||||
return "Top pane"
|
||||
}
|
||||
}
|
||||
|
||||
private var rightPaneLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Right pane"
|
||||
case .vertical:
|
||||
return "Bottom pane"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SplitViewDirection: Codable {
|
||||
|
@@ -32,6 +32,8 @@ struct TerminalSplitSubtreeView: View {
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: leafView,
|
||||
isSplit: !isRoot)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
|
||||
case .split(let split):
|
||||
let splitViewDirection: SplitViewDirection = switch (split.direction) {
|
||||
|
@@ -139,6 +139,10 @@ struct DebugBuildWarningView: View {
|
||||
}
|
||||
.background(Color(.windowBackgroundColor))
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Debug build warning")
|
||||
.accessibilityValue("Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development.")
|
||||
.accessibilityAddTraits(.isStaticText)
|
||||
.onTapGesture {
|
||||
isPopover = true
|
||||
}
|
||||
|
@@ -138,6 +138,9 @@ extension Ghostty {
|
||||
// by the user, this is set to the prior value (which may be empty, but non-nil).
|
||||
private var titleFromTerminal: String?
|
||||
|
||||
// The cached contents of the screen.
|
||||
private var cachedScreenContents: CachedValue<String>
|
||||
|
||||
/// Event monitor (see individual events for why)
|
||||
private var eventMonitor: Any? = nil
|
||||
|
||||
@@ -159,11 +162,38 @@ extension Ghostty {
|
||||
self.derivedConfig = DerivedConfig()
|
||||
}
|
||||
|
||||
// We need to initialize this so it does something but we want to set
|
||||
// it back up later so we can reference `self`. This is a hack we should
|
||||
// fix at some point.
|
||||
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
|
||||
|
||||
// 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
|
||||
// can do SOMETHING.
|
||||
super.init(frame: NSMakeRect(0, 0, 800, 600))
|
||||
|
||||
// Our cache of screen data
|
||||
cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in
|
||||
guard let self else { return "" }
|
||||
guard let surface = self.surface else { return "" }
|
||||
var text = ghostty_text_s()
|
||||
let sel = ghostty_selection_s(
|
||||
top_left: ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_SCREEN,
|
||||
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
x: 0,
|
||||
y: 0),
|
||||
bottom_right: ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_SCREEN,
|
||||
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
x: 0,
|
||||
y: 0),
|
||||
rectangle: false)
|
||||
guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
return String(cString: text.text)
|
||||
}
|
||||
|
||||
// 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
|
||||
if let self = self, self.title.isEmpty {
|
||||
@@ -1215,11 +1245,10 @@ extension Ghostty {
|
||||
guard let surface = self.surface else { return super.quickLook(with: event) }
|
||||
|
||||
// Grab the text under the cursor
|
||||
var info: ghostty_selection_s = ghostty_selection_s();
|
||||
let text = String(unsafeUninitializedCapacity: 1000000) {
|
||||
Int(ghostty_surface_quicklook_word(surface, $0.baseAddress, UInt($0.count), &info))
|
||||
}
|
||||
guard !text.isEmpty else { return super.quickLook(with: event) }
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_quicklook_word(surface, &text) else { return super.quickLook(with: event) }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
guard text.text_len > 0 else { return super.quickLook(with: event) }
|
||||
|
||||
// If we can get a font then we use the font. This should always work
|
||||
// since we always have a primary font. The only scenario this doesn't
|
||||
@@ -1236,8 +1265,8 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
// Ghostty coordinate system is top-left, convert to bottom-left for AppKit
|
||||
let pt = NSMakePoint(info.tl_px_x, frame.size.height - info.tl_px_y)
|
||||
let str = NSAttributedString.init(string: text, attributes: attributes)
|
||||
let pt = NSMakePoint(text.tl_px_x, frame.size.height - text.tl_px_y)
|
||||
let str = NSAttributedString.init(string: String(cString: text.text), attributes: attributes)
|
||||
self.showDefinition(for: str, at: pt);
|
||||
}
|
||||
|
||||
@@ -1522,9 +1551,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
// Get our range from the Ghostty API. There is a race condition between getting the
|
||||
// range and actually using it since our selection may change but there isn't a good
|
||||
// way I can think of to solve this for AppKit.
|
||||
var sel: ghostty_selection_s = ghostty_selection_s();
|
||||
guard ghostty_surface_selection_info(surface, &sel) else { return NSRange() }
|
||||
return NSRange(location: Int(sel.offset_start), length: Int(sel.offset_len))
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(surface, &text) else { return NSRange() }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
return NSRange(location: Int(text.offset_start), length: Int(text.offset_len))
|
||||
}
|
||||
|
||||
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
||||
@@ -1562,7 +1592,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
||||
// Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())")
|
||||
guard let surface = self.surface else { return nil }
|
||||
guard ghostty_surface_has_selection(surface) else { return nil }
|
||||
|
||||
// If the range is empty then we don't need to return anything
|
||||
guard range.length > 0 else { return nil }
|
||||
@@ -1572,11 +1601,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
// bogus ranges I truly don't understand so we just always return the
|
||||
// attributed string containing our selection which is... weird but works?
|
||||
|
||||
// Get our selection. We cap it at 1MB for the purpose of this. This is
|
||||
// arbitrary. If this is a good reason to increase it I'm happy to.
|
||||
let v = String(unsafeUninitializedCapacity: 1000000) {
|
||||
Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
|
||||
}
|
||||
// Get our selection text
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(surface, &text) else { return nil }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
|
||||
// If we can get a font then we use the font. This should always work
|
||||
// since we always have a primary font. The only scenario this doesn't
|
||||
@@ -1592,7 +1620,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
font.release()
|
||||
}
|
||||
|
||||
return .init(string: v, attributes: attributes)
|
||||
return .init(string: String(cString: text.text), attributes: attributes)
|
||||
}
|
||||
|
||||
func characterIndex(for point: NSPoint) -> Int {
|
||||
@@ -1614,12 +1642,15 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
// point right now. I'm sure I'm missing something fundamental...
|
||||
if range.length > 0 && range != self.selectedRange() {
|
||||
// QuickLook
|
||||
var sel: ghostty_selection_s = ghostty_selection_s();
|
||||
if ghostty_surface_selection_info(surface, &sel) {
|
||||
var text = ghostty_text_s()
|
||||
if ghostty_surface_read_selection(surface, &text) {
|
||||
// The -2/+2 here is subjective. QuickLook seems to offset the rectangle
|
||||
// a bit and I think these small adjustments make it look more natural.
|
||||
x = sel.tl_px_x - 2;
|
||||
y = sel.tl_px_y + 2;
|
||||
x = text.tl_px_x - 2;
|
||||
y = text.tl_px_y + 2;
|
||||
|
||||
// Free our text
|
||||
ghostty_surface_free_text(surface, &text)
|
||||
} else {
|
||||
ghostty_surface_ime_point(surface, &x, &y)
|
||||
}
|
||||
@@ -1745,14 +1776,13 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
) -> Bool {
|
||||
guard let surface = self.surface else { return false }
|
||||
|
||||
// We currently cap the maximum copy size to 1MB. iTerm2 I believe
|
||||
// caps theirs at 0.1MB (configurable) so this is probably reasonable.
|
||||
let v = String(unsafeUninitializedCapacity: 1000000) {
|
||||
Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
|
||||
}
|
||||
// Read the selection
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(surface, &text) else { return false }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
|
||||
pboard.declareTypes([.string], owner: nil)
|
||||
pboard.setString(v, forType: .string)
|
||||
pboard.setString(String(cString: text.text), forType: .string)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1844,3 +1874,148 @@ extension Ghostty.SurfaceView {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
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
|
||||
/// and other assistive technologies.
|
||||
override func isAccessibilityElement() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Defines the accessibility role for this view, which helps assistive technologies
|
||||
/// understand what kind of content this view contains and how users can interact with it.
|
||||
override func accessibilityRole() -> NSAccessibility.Role? {
|
||||
/// We use .textArea because the terminal surface is essentially an editable text area
|
||||
/// where users can input commands and view output.
|
||||
return .textArea
|
||||
}
|
||||
|
||||
override func accessibilityHelp() -> String? {
|
||||
return "Terminal content area"
|
||||
}
|
||||
|
||||
override func accessibilityValue() -> Any? {
|
||||
return cachedScreenContents.get()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Returns the currently selected text as a string.
|
||||
/// This allows assistive technologies to read the selected content.
|
||||
override func accessibilitySelectedText() -> String? {
|
||||
guard let surface = self.surface else { return nil }
|
||||
|
||||
// Attempt to read the selection
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(surface, &text) else { return nil }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
|
||||
let str = String(cString: text.text)
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Returns the line number for a given character index.
|
||||
/// This helps assistive technologies navigate by 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
|
||||
}
|
||||
|
||||
/// 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()
|
||||
guard let swiftRange = Range(range, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
|
||||
/// Returns an attributed string for the given range.
|
||||
///
|
||||
/// Note: right now this only applies font information. One day it'd be nice to extend
|
||||
/// this to copy styling information as well but we need to augment Ghostty core to
|
||||
/// expose that.
|
||||
///
|
||||
/// This provides styling information to assistive technologies.
|
||||
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
|
||||
guard let surface = self.surface else { return nil }
|
||||
guard let plainString = accessibilityString(for: range) else { return nil }
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
|
||||
// Try to get the font from the surface
|
||||
if let fontRaw = ghostty_surface_quicklook_font(surface) {
|
||||
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
|
||||
attributes[.font] = font.takeUnretainedValue()
|
||||
font.release()
|
||||
}
|
||||
|
||||
return NSAttributedString(string: plainString, attributes: attributes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches a value for some period of time, evicting it automatically when that time expires.
|
||||
/// We use this to cache our surface content. This probably should be extracted some day
|
||||
/// to a more generic helper.
|
||||
fileprivate class CachedValue<T> {
|
||||
private var value: T?
|
||||
private let fetch: () -> T
|
||||
private let duration: Duration
|
||||
private var expiryTask: Task<Void, Never>?
|
||||
|
||||
init(duration: Duration, fetch: @escaping () -> T) {
|
||||
self.duration = duration
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
deinit {
|
||||
expiryTask?.cancel()
|
||||
}
|
||||
|
||||
func get() -> T {
|
||||
if let value {
|
||||
return value
|
||||
}
|
||||
|
||||
// We don't have a value (or it expired). Fetch and store.
|
||||
let result = fetch()
|
||||
let now = ContinuousClock.now
|
||||
let expires = now + duration
|
||||
self.value = result
|
||||
|
||||
// Schedule a task to clear the value
|
||||
expiryTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(until: expires)
|
||||
self?.value = nil
|
||||
self?.expiryTask = nil
|
||||
} catch {
|
||||
// Task was cancelled, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
127
src/Surface.zig
127
src/Surface.zig
@@ -1292,6 +1292,133 @@ fn recomputeInitialSize(
|
||||
) catch return error.AppActionFailed;
|
||||
}
|
||||
|
||||
/// Represents text read from the terminal and some metadata about it
|
||||
/// that is often useful to apprts.
|
||||
pub const Text = struct {
|
||||
/// The text that was read from the terminal.
|
||||
text: [:0]const u8,
|
||||
|
||||
/// The viewport information about this text, if it is visible in
|
||||
/// the viewport.
|
||||
///
|
||||
/// NOTE(mitchellh): This will only be non-null currently if the entirety
|
||||
/// of the selection is contained within the viewport. We don't have a
|
||||
/// use case currently for partial bounds but we should support this
|
||||
/// eventually.
|
||||
viewport: ?Viewport = null,
|
||||
|
||||
pub const Viewport = struct {
|
||||
/// The top-left corner of the selection in pixels within the viewport.
|
||||
tl_px_x: f64,
|
||||
tl_px_y: f64,
|
||||
|
||||
/// The linear offset of the start of the selection and the length.
|
||||
/// This is "linear" in the sense that it is the offset in the
|
||||
/// flattened viewport as a single array of text.
|
||||
offset_start: u32,
|
||||
offset_len: u32,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Text, alloc: Allocator) void {
|
||||
alloc.free(self.text);
|
||||
}
|
||||
};
|
||||
|
||||
/// Grab the value of text at the given selection point. Note that the
|
||||
/// selection structure is used as a way to determine the area of the
|
||||
/// screen to read from, it doesn't have to match the user's current
|
||||
/// selection state.
|
||||
///
|
||||
/// The returned value contains allocated data and must be deinitialized.
|
||||
pub fn dumpText(
|
||||
self: *Surface,
|
||||
alloc: Allocator,
|
||||
sel: terminal.Selection,
|
||||
) !Text {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
return try self.dumpTextLocked(alloc, sel);
|
||||
}
|
||||
|
||||
/// Same as `dumpText` but assumes the renderer state mutex is already
|
||||
/// held.
|
||||
pub fn dumpTextLocked(
|
||||
self: *Surface,
|
||||
alloc: Allocator,
|
||||
sel: terminal.Selection,
|
||||
) !Text {
|
||||
// Read out the text
|
||||
const text = try self.io.terminal.screen.selectionString(alloc, .{
|
||||
.sel = sel,
|
||||
.trim = false,
|
||||
});
|
||||
errdefer alloc.free(text);
|
||||
|
||||
// Calculate our viewport info if we can.
|
||||
const vp: ?Text.Viewport = viewport: {
|
||||
// If our tl or br is not in the viewport then we don't
|
||||
// have a viewport. One day we should extend this to support
|
||||
// partial selections that are in the viewport.
|
||||
const tl_pt = self.io.terminal.screen.pages.pointFromPin(
|
||||
.viewport,
|
||||
sel.topLeft(&self.io.terminal.screen),
|
||||
) orelse break :viewport null;
|
||||
const br_pt = self.io.terminal.screen.pages.pointFromPin(
|
||||
.viewport,
|
||||
sel.bottomRight(&self.io.terminal.screen),
|
||||
) orelse break :viewport null;
|
||||
const tl_coord = tl_pt.coord();
|
||||
const br_coord = br_pt.coord();
|
||||
|
||||
// Our sizes are all scaled so we need to send the unscaled values back.
|
||||
const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };
|
||||
const x: f64 = x: {
|
||||
// Simple x * cell width gives the left
|
||||
var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width);
|
||||
|
||||
// Add padding
|
||||
x += @floatFromInt(self.size.padding.left);
|
||||
|
||||
// Scale
|
||||
x /= content_scale.x;
|
||||
|
||||
break :x x;
|
||||
};
|
||||
const y: f64 = y: {
|
||||
// Simple y * cell height gives the top
|
||||
var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height);
|
||||
|
||||
// We want the text baseline
|
||||
y += @floatFromInt(self.size.cell.height);
|
||||
y -= @floatFromInt(self.font_metrics.cell_baseline);
|
||||
|
||||
// Add padding
|
||||
y += @floatFromInt(self.size.padding.top);
|
||||
|
||||
// Scale
|
||||
y /= content_scale.y;
|
||||
|
||||
break :y y;
|
||||
};
|
||||
|
||||
// Utilize viewport sizing to convert to offsets
|
||||
const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x;
|
||||
const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x;
|
||||
|
||||
break :viewport .{
|
||||
.tl_px_x = x,
|
||||
.tl_px_y = y,
|
||||
.offset_start = start,
|
||||
.offset_len = end - start,
|
||||
};
|
||||
};
|
||||
|
||||
return .{
|
||||
.text = text,
|
||||
.viewport = vp,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns true if the terminal has a selection.
|
||||
pub fn hasSelection(self: *const Surface) bool {
|
||||
self.renderer_state.mutex.lock();
|
||||
|
@@ -1138,13 +1138,6 @@ pub const CAPI = struct {
|
||||
}
|
||||
};
|
||||
|
||||
const Selection = extern struct {
|
||||
tl_x_px: f64,
|
||||
tl_y_px: f64,
|
||||
offset_start: u32,
|
||||
offset_len: u32,
|
||||
};
|
||||
|
||||
const SurfaceSize = extern struct {
|
||||
columns: u16,
|
||||
rows: u16,
|
||||
@@ -1154,6 +1147,104 @@ pub const CAPI = struct {
|
||||
cell_height_px: u32,
|
||||
};
|
||||
|
||||
// ghostty_text_s
|
||||
const Text = extern struct {
|
||||
tl_px_x: f64,
|
||||
tl_px_y: f64,
|
||||
offset_start: u32,
|
||||
offset_len: u32,
|
||||
text: ?[*:0]const u8,
|
||||
text_len: usize,
|
||||
|
||||
pub fn deinit(self: *Text) void {
|
||||
if (self.text) |ptr| {
|
||||
global.alloc.free(ptr[0..self.text_len :0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ghostty_point_s
|
||||
const Point = extern struct {
|
||||
tag: Tag,
|
||||
coord_tag: CoordTag,
|
||||
x: u32,
|
||||
y: u32,
|
||||
|
||||
const Tag = enum(c_int) {
|
||||
active = 0,
|
||||
viewport = 1,
|
||||
screen = 2,
|
||||
history = 3,
|
||||
};
|
||||
|
||||
const CoordTag = enum(c_int) {
|
||||
exact = 0,
|
||||
top_left = 1,
|
||||
bottom_right = 2,
|
||||
};
|
||||
|
||||
fn pin(
|
||||
self: Point,
|
||||
screen: *const terminal.Screen,
|
||||
) ?terminal.Pin {
|
||||
// The core point tag.
|
||||
const tag: terminal.point.Tag = switch (self.tag) {
|
||||
inline else => |tag| @field(
|
||||
terminal.point.Tag,
|
||||
@tagName(tag),
|
||||
),
|
||||
};
|
||||
|
||||
// Clamp our point to the screen bounds.
|
||||
const clamped_x = @min(self.x, screen.pages.cols -| 1);
|
||||
const clamped_y = @min(self.y, screen.pages.rows -| 1);
|
||||
|
||||
return switch (self.coord_tag) {
|
||||
// Exact coordinates require a specific pin.
|
||||
.exact => exact: {
|
||||
const pt_x = std.math.cast(
|
||||
terminal.size.CellCountInt,
|
||||
clamped_x,
|
||||
) orelse std.math.maxInt(terminal.size.CellCountInt);
|
||||
|
||||
const pt: terminal.Point = switch (tag) {
|
||||
inline else => |v| @unionInit(
|
||||
terminal.Point,
|
||||
@tagName(v),
|
||||
.{ .x = pt_x, .y = clamped_y },
|
||||
),
|
||||
};
|
||||
|
||||
break :exact screen.pages.pin(pt) orelse null;
|
||||
},
|
||||
|
||||
.top_left => screen.pages.getTopLeft(tag),
|
||||
|
||||
.bottom_right => screen.pages.getBottomRight(tag),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ghostty_selection_s
|
||||
const Selection = extern struct {
|
||||
tl: Point,
|
||||
br: Point,
|
||||
rectangle: bool,
|
||||
|
||||
fn core(
|
||||
self: Selection,
|
||||
screen: *const terminal.Screen,
|
||||
) ?terminal.Selection {
|
||||
return .{
|
||||
.bounds = .{ .untracked = .{
|
||||
.start = self.tl.pin(screen) orelse return null,
|
||||
.end = self.br.pin(screen) orelse return null,
|
||||
} },
|
||||
.rectangle = self.rectangle,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Reference the conditional exports based on target platform
|
||||
// so they're included in the C API.
|
||||
comptime {
|
||||
@@ -1369,23 +1460,80 @@ pub const CAPI = struct {
|
||||
return surface.core_surface.hasSelection();
|
||||
}
|
||||
|
||||
/// Copies the surface selection text into the provided buffer and
|
||||
/// returns the copied size. If the buffer is too small, there is no
|
||||
/// selection, or there is an error, then 0 is returned.
|
||||
export fn ghostty_surface_selection(surface: *Surface, buf: [*]u8, cap: usize) usize {
|
||||
const selection_ = surface.core_surface.selectionString(global.alloc) catch |err| {
|
||||
log.warn("error getting selection err={}", .{err});
|
||||
return 0;
|
||||
/// Same as ghostty_surface_read_text but reads from the user selection,
|
||||
/// if any.
|
||||
export fn ghostty_surface_read_selection(
|
||||
surface: *Surface,
|
||||
result: *Text,
|
||||
) bool {
|
||||
const core_surface = &surface.core_surface;
|
||||
core_surface.renderer_state.mutex.lock();
|
||||
defer core_surface.renderer_state.mutex.unlock();
|
||||
|
||||
// If we don't have a selection, do nothing.
|
||||
const core_sel = core_surface.io.terminal.screen.selection orelse return false;
|
||||
|
||||
// Read the text from the selection.
|
||||
return readTextLocked(surface, core_sel, result);
|
||||
}
|
||||
|
||||
/// Read some arbitrary text from the surface.
|
||||
///
|
||||
/// This is an expensive operation so it shouldn't be called too
|
||||
/// often. We recommend that callers cache the result and throttle
|
||||
/// calls to this function.
|
||||
export fn ghostty_surface_read_text(
|
||||
surface: *Surface,
|
||||
sel: Selection,
|
||||
result: *Text,
|
||||
) bool {
|
||||
surface.core_surface.renderer_state.mutex.lock();
|
||||
defer surface.core_surface.renderer_state.mutex.unlock();
|
||||
|
||||
const core_sel = sel.core(
|
||||
&surface.core_surface.renderer_state.terminal.screen,
|
||||
) orelse return false;
|
||||
|
||||
return readTextLocked(surface, core_sel, result);
|
||||
}
|
||||
|
||||
fn readTextLocked(
|
||||
surface: *Surface,
|
||||
core_sel: terminal.Selection,
|
||||
result: *Text,
|
||||
) bool {
|
||||
const core_surface = &surface.core_surface;
|
||||
|
||||
// Get our text directly from the core surface.
|
||||
const text = core_surface.dumpTextLocked(
|
||||
global.alloc,
|
||||
core_sel,
|
||||
) catch |err| {
|
||||
log.warn("error reading text err={}", .{err});
|
||||
return false;
|
||||
};
|
||||
const selection = selection_ orelse return 0;
|
||||
defer global.alloc.free(selection);
|
||||
|
||||
// If the buffer is too small, return no selection.
|
||||
if (selection.len > cap) return 0;
|
||||
const vp: CoreSurface.Text.Viewport = text.viewport orelse .{
|
||||
.tl_px_x = -1,
|
||||
.tl_px_y = -1,
|
||||
.offset_start = 0,
|
||||
.offset_len = 0,
|
||||
};
|
||||
|
||||
// Copy into the buffer and return the length
|
||||
@memcpy(buf[0..selection.len], selection);
|
||||
return selection.len;
|
||||
result.* = .{
|
||||
.tl_px_x = vp.tl_px_x,
|
||||
.tl_px_y = vp.tl_px_y,
|
||||
.offset_start = vp.offset_start,
|
||||
.offset_len = vp.offset_len,
|
||||
.text = text.text.ptr,
|
||||
.text_len = text.text.len,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export fn ghostty_surface_free_text(ptr: *Text) void {
|
||||
ptr.deinit();
|
||||
}
|
||||
|
||||
/// Tell the surface that it needs to schedule a render
|
||||
@@ -1888,21 +2036,12 @@ pub const CAPI = struct {
|
||||
/// This does not modify the selection active on the surface (if any).
|
||||
export fn ghostty_surface_quicklook_word(
|
||||
ptr: *Surface,
|
||||
buf: [*]u8,
|
||||
cap: usize,
|
||||
info: *Selection,
|
||||
) usize {
|
||||
result: *Text,
|
||||
) bool {
|
||||
const surface = &ptr.core_surface;
|
||||
surface.renderer_state.mutex.lock();
|
||||
defer surface.renderer_state.mutex.unlock();
|
||||
|
||||
// To make everything in this function easier, we modify the
|
||||
// selection to be the word under the cursor and call normal APIs.
|
||||
// We restore the old selection so it isn't ever changed. Since we hold
|
||||
// the renderer mutex it'll never show up in a frame.
|
||||
const prev = surface.io.terminal.screen.selection;
|
||||
defer surface.io.terminal.screen.selection = prev;
|
||||
|
||||
// Get our word selection
|
||||
const sel = sel: {
|
||||
const screen = &surface.renderer_state.terminal.screen;
|
||||
@@ -1915,45 +2054,13 @@ pub const CAPI = struct {
|
||||
},
|
||||
}) orelse {
|
||||
if (comptime std.debug.runtime_safety) unreachable;
|
||||
return 0;
|
||||
return false;
|
||||
};
|
||||
break :sel surface.io.terminal.screen.selectWord(pin) orelse return 0;
|
||||
break :sel surface.io.terminal.screen.selectWord(pin) orelse return false;
|
||||
};
|
||||
|
||||
// Set the selection
|
||||
surface.io.terminal.screen.selection = sel;
|
||||
|
||||
// No we call normal functions. These require that the lock
|
||||
// is unlocked. This may cause a frame flicker with the fake
|
||||
// selection but I think the lack of new complexity is worth it
|
||||
// for now.
|
||||
{
|
||||
surface.renderer_state.mutex.unlock();
|
||||
defer surface.renderer_state.mutex.lock();
|
||||
const len = ghostty_surface_selection(ptr, buf, cap);
|
||||
if (!ghostty_surface_selection_info(ptr, info)) return 0;
|
||||
return len;
|
||||
}
|
||||
}
|
||||
|
||||
/// This returns the selection metadata for the current selection.
|
||||
/// This will return false if there is no selection or the
|
||||
/// selection is not fully contained in the viewport (since the
|
||||
/// metadata is all about that).
|
||||
export fn ghostty_surface_selection_info(
|
||||
ptr: *Surface,
|
||||
info: *Selection,
|
||||
) bool {
|
||||
const sel = ptr.core_surface.selectionInfo() orelse
|
||||
return false;
|
||||
|
||||
info.* = .{
|
||||
.tl_x_px = sel.tl_x_px,
|
||||
.tl_y_px = sel.tl_y_px,
|
||||
.offset_start = sel.offset_start,
|
||||
.offset_len = sel.offset_len,
|
||||
};
|
||||
return true;
|
||||
// Read the selection
|
||||
return readTextLocked(ptr, sel, result);
|
||||
}
|
||||
|
||||
export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {
|
||||
|
@@ -35,6 +35,7 @@ pub const Page = page.Page;
|
||||
pub const PageList = @import("PageList.zig");
|
||||
pub const Parser = @import("Parser.zig");
|
||||
pub const Pin = PageList.Pin;
|
||||
pub const Point = point.Point;
|
||||
pub const Screen = @import("Screen.zig");
|
||||
pub const ScreenType = Terminal.ScreenType;
|
||||
pub const Selection = @import("Selection.zig");
|
||||
|
Reference in New Issue
Block a user