From 7cc7f6cb06e67b2c5d11575bfac0d7a377d36150 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 06:51:00 -0700 Subject: [PATCH 01/11] macos 15 regression: transparent style shouldn't draw border This fixes a regression from our Tahoe window styling changes on earlier, stable versions of macOS. We need to set "titlebarAppearsTransparent" to true in order to hide the bottom border. --- .../Window Styles/TransparentTitlebarTerminalWindow.swift | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 57c79fa357bb4dce1770db99ba3269682902e460 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 07:48:14 -0700 Subject: [PATCH 02/11] macos: Tahoe menu item icons, missed the "Ghostty" menu entirely This is a follow up to #7594, I missed an entire menu. --- macos/Sources/App/macOS/AppDelegate.swift | 6 ++++++ macos/Sources/App/macOS/MainMenu.xib | 1 + 2 files changed, 7 insertions(+) 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 @@ + From 4237dad240089f35643b5f83ccf83c17323bb694 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 12:20:15 -0700 Subject: [PATCH 03/11] macOS: simple SplitView AX Proper labels, action to move the divider --- .../Features/Splits/SplitView.Divider.swift | 35 +++++++++++++++++ macos/Sources/Features/Splits/SplitView.swift | 38 ++++++++++++++++++- .../Splits/TerminalSplitTreeView.swift | 2 + 3 files changed, 74 insertions(+), 1 deletion(-) 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) { From c90eb2e9525288d790d5a5be5e80a9f7272c1318 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 12:48:42 -0700 Subject: [PATCH 04/11] macos: AX for debug warning --- macos/Sources/Features/Terminal/TerminalView.swift | 4 ++++ 1 file changed, 4 insertions(+) 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 } From be437f5b64c903f82ee04f039541af83e7f08897 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 14:09:23 -0700 Subject: [PATCH 05/11] macos: bare minimum terminal ax --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 3e87176fc..3f9bb5e53 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1844,3 +1844,26 @@ 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" + } +} From c5f921bb066d5fb6b50d3a5570ef727a2f5ea35f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 15:48:03 -0700 Subject: [PATCH 06/11] apprt/embedded: improve text reading APIs (selection, random points) --- include/ghostty.h | 33 ++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 75 +++--- src/Surface.zig | 127 ++++++++++ src/apprt/embedded.zig | 222 ++++++++++++------ src/terminal/main.zig | 1 + 5 files changed, 357 insertions(+), 101 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 9f17d0b97..9fc58aa87 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -355,6 +355,27 @@ 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 struct { + ghostty_point_tag_e tag; + 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 +853,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/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 3f9bb5e53..cf9252c88 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1215,11 +1215,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 +1235,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 +1521,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 +1562,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 +1571,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 +1590,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 +1612,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 +1746,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 } @@ -1866,4 +1866,25 @@ extension Ghostty.SurfaceView { override func accessibilityHelp() -> String? { return "Terminal content area" } + + /// 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 + } } 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..dbc74e6ae 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,83 @@ 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, + x: u32, + y: u32, + + const Tag = enum(c_int) { + active = 0, + viewport = 1, + screen = 2, + history = 3, + }; + + fn core(self: Point) terminal.Point { + // This comes from the C API so we can't trust the input. + const pt_x = std.math.cast( + terminal.size.CellCountInt, + self.x, + ) orelse std.math.maxInt(terminal.size.CellCountInt); + + return switch (self.tag) { + inline else => |tag| @unionInit( + terminal.Point, + @tagName(tag), + .{ .x = pt_x, .y = self.y }, + ), + }; + } + + fn clamp(self: Point, screen: *const terminal.Screen) Point { + // 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 .{ .tag = self.tag, .x = clamped_x, .y = clamped_y }; + } + }; + + // 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 = screen.pages.pin( + self.tl.clamp(screen).core(), + ) orelse return null, + .end = screen.pages.pin( + self.br.clamp(screen).core(), + ) 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 +1439,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 +2015,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 +2033,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/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"); From e1ee180172ae5ba192dd4f272a70e21df11138ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 13:06:18 -0700 Subject: [PATCH 07/11] apprt/embedded: API to read text can get top left/bottom right coords --- include/ghostty.h | 7 +++++ src/apprt/embedded.zig | 59 ++++++++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 9fc58aa87..fc2c915cb 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -366,8 +366,15 @@ typedef enum { 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; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index dbc74e6ae..a61c75e96 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1166,6 +1166,7 @@ pub const CAPI = struct { // ghostty_point_s const Point = extern struct { tag: Tag, + coord_tag: CoordTag, x: u32, y: u32, @@ -1176,27 +1177,51 @@ pub const CAPI = struct { history = 3, }; - fn core(self: Point) terminal.Point { - // This comes from the C API so we can't trust the input. - const pt_x = std.math.cast( - terminal.size.CellCountInt, - self.x, - ) orelse std.math.maxInt(terminal.size.CellCountInt); + const CoordTag = enum(c_int) { + exact = 0, + top_left = 1, + bottom_right = 2, + }; - return switch (self.tag) { - inline else => |tag| @unionInit( - terminal.Point, + 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), - .{ .x = pt_x, .y = self.y }, ), }; - } - fn clamp(self: Point, screen: *const terminal.Screen) Point { // 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 .{ .tag = self.tag, .x = clamped_x, .y = clamped_y }; + + 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), + }; } }; @@ -1212,12 +1237,8 @@ pub const CAPI = struct { ) ?terminal.Selection { return .{ .bounds = .{ .untracked = .{ - .start = screen.pages.pin( - self.tl.clamp(screen).core(), - ) orelse return null, - .end = screen.pages.pin( - self.br.clamp(screen).core(), - ) orelse return null, + .start = self.tl.pin(screen) orelse return null, + .end = self.br.pin(screen) orelse return null, } }, .rectangle = self.rectangle, }; From 839d89f2dcbc77dd0f1de9e34f0e5e5eb5e21316 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 13:46:34 -0700 Subject: [PATCH 08/11] macos: simple cache of screen contents for ax --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index cf9252c88..046e79a23 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 { @@ -1866,7 +1896,11 @@ extension Ghostty.SurfaceView { 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. @@ -1888,3 +1922,43 @@ extension Ghostty.SurfaceView { return str.isEmpty ? nil : str } } + +/// 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. +/// +// TODO: +// - Auto-expire the data so it doesn't take memory +fileprivate class CachedValue { + private var value: Value? + private let fetch: () -> T + private let duration: Duration + + struct Value { + var value: T + var expires: ContinuousClock.Instant + } + + init(duration: Duration, fetch: @escaping () -> T) { + self.duration = duration + self.fetch = fetch + } + + func get() -> T { + let now = ContinuousClock.now + if let value { + // If the value isn't expired just return it + if value.expires > now { + return value.value + } + + // Value is expired, clear it + self.value = nil + } + + // We don't have a value (or it expired). Fetch and store. + let result = fetch() + self.value = .init(value: result, expires: now + duration) + return result + } +} From e69c756c895f1cc9106e22d419037a926c3176c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 13:55:03 -0700 Subject: [PATCH 09/11] macos: auto-expire cached screen contents --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 046e79a23..57fa56e77 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1926,39 +1926,43 @@ extension Ghostty.SurfaceView { /// 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. -/// -// TODO: -// - Auto-expire the data so it doesn't take memory fileprivate class CachedValue { - private var value: Value? + private var value: T? private let fetch: () -> T private let duration: Duration - - struct Value { - var value: T - var expires: ContinuousClock.Instant - } + private var expiryTask: Task? init(duration: Duration, fetch: @escaping () -> T) { self.duration = duration self.fetch = fetch } - func get() -> T { - let now = ContinuousClock.now - if let value { - // If the value isn't expired just return it - if value.expires > now { - return value.value - } + deinit { + expiryTask?.cancel() + } - // Value is expired, clear it - self.value = nil + func get() -> T { + if let value { + return value } // We don't have a value (or it expired). Fetch and store. let result = fetch() - self.value = .init(value: result, expires: now + duration) + 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 } } From a2b4a2c0e42cb2bdf970598083e59e5be88f511d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Jun 2025 14:00:39 -0700 Subject: [PATCH 10/11] macos: complete more ax APIs for terminal accessibility --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 57fa56e77..a47dbdaca 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1921,6 +1921,59 @@ extension Ghostty.SurfaceView { 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. From b629f3337a147f7a2f77f801b6fd621c90cfb4d2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 16 Jun 2025 19:32:42 -0400 Subject: [PATCH 11/11] bash: remove dependency on $GHOSTTY_RESOURCES_DIR We were depending on $GHOSTTY_RESOURCES_DIR for two reasons: 1. To locate our script-adjacent bash-preexec.sh script 2. To restrict our script's execution to environments in which $GHOSTTY_RESOURCES_DIR is available (i.e. Ghostty-only shells) For (1), we can instead determine our directory using $BASH_SOURCE[0]. This is slightly differently than our previous behavior, where we'd always load bash-preexec.sh from the $GHOSTTY_RESOURCES_DIR hierarchy, even if ghostty.bash from source from somewhere else on the file system ... but we never relied on that behavior, even in development. For (2), there's no harm in source'ing this script outside of Ghostty, and if that does become a concern, we can restore this condition or use something more targeted based on those specific cases. Historically, I believe (2) was in place to enable (1), so addressing (1) removes the need for (2). And lastly, none of the other shell integration scripts depend on $GHOSTTY_RESOURCES_DIR. --- src/shell-integration/bash/ghostty.bash | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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.