From c5f921bb066d5fb6b50d3a5570ef727a2f5ea35f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 15:48:03 -0700 Subject: [PATCH] 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");