diff --git a/include/ghostty.h b/include/ghostty.h
index 9f17d0b97..fc2c915cb 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -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);
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index f460017f5..c56d7c3ac 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -18,6 +18,7 @@ class AppDelegate: NSObject,
)
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config
+ @IBOutlet private var menuAbout: NSMenuItem?
@IBOutlet private var menuServices: NSMenu?
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@@ -399,6 +400,11 @@ class AppDelegate: NSObject,
private func setupMenuImages() {
// Note: This COULD Be done all in the xib file, but I find it easier to
// modify this stuff as code.
+ self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
+ self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
+ self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
+ self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
+ self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib
index c9bff8b4a..5cd6d9bec 100644
--- a/macos/Sources/App/macOS/MainMenu.xib
+++ b/macos/Sources/App/macOS/MainMenu.xib
@@ -14,6 +14,7 @@
+
diff --git a/macos/Sources/Features/Splits/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift
index 83847ff0c..a01175dce 100644
--- a/macos/Sources/Features/Splits/SplitView.Divider.swift
+++ b/macos/Sources/Features/Splits/SplitView.Divider.swift
@@ -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"
+ }
}
}
}
diff --git a/macos/Sources/Features/Splits/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift
index 9747ac99f..3dc3c36a3 100644
--- a/macos/Sources/Features/Splits/SplitView.swift
+++ b/macos/Sources/Features/Splits/SplitView.swift
@@ -42,16 +42,23 @@ struct SplitView: 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: 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 {
diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift
index 2810fc2b4..f19640707 100644
--- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift
+++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift
@@ -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) {
diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift
index cb6f11bce..b5be0ae42 100644
--- a/macos/Sources/Features/Terminal/TerminalView.swift
+++ b/macos/Sources/Features/Terminal/TerminalView.swift
@@ -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
}
diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
index 1a92fa024..0d064a7f7 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
@@ -96,9 +96,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
@available(macOS 13.0, *)
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let titlebarContainer else { return }
+
+ // Setup the titlebar background color to match ours
titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
+
+ // See the docs for the function that sets this to true on why
effectViewIsHidden = false
+
+ // Necessary to not draw the border around the title
+ titlebarAppearsTransparent = true
}
// MARK: View Finders
diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
index 3e87176fc..a47dbdaca 100644
--- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift
+++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
@@ -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
+
/// 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.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 {
+ private var value: T?
+ private let fetch: () -> T
+ private let duration: Duration
+ private var expiryTask: Task?
+
+ 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
+ }
+}
diff --git a/src/Surface.zig b/src/Surface.zig
index 9ab7234d6..41d40125a 100644
--- a/src/Surface.zig
+++ b/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();
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 5334c8ecd..a61c75e96 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -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 {
diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash
index 0cfd41663..0766198f9 100644
--- a/src/shell-integration/bash/ghostty.bash
+++ b/src/shell-integration/bash/ghostty.bash
@@ -15,10 +15,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-# We need to be in interactive mode and we need to have the Ghostty
-# resources dir set which also tells us we're running in Ghostty.
+# We need to be in interactive mode to proceed.
if [[ "$-" != *i* ]] ; then builtin return; fi
-if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi
# When automatic shell integration is active, we were started in POSIX
# mode and need to manually recreate the bash startup sequence.
@@ -98,7 +96,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
fi
# Import bash-preexec, safe to do multiple times
-builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"
+builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
# This is set to 1 when we're executing a command so that we don't
# send prompt marks multiple times.
diff --git a/src/terminal/main.zig b/src/terminal/main.zig
index df3788d30..74ffe6341 100644
--- a/src/terminal/main.zig
+++ b/src/terminal/main.zig
@@ -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");