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");