diff --git a/include/ghostty.h b/include/ghostty.h index 19a200f10..05f674368 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -387,6 +387,13 @@ typedef struct { uintptr_t text_len; } ghostty_text_s; +typedef struct { + const char* text; + uintptr_t text_len; + uintptr_t viewport_start; + uintptr_t viewport_end; +} ghostty_ax_text_s; + typedef enum { GHOSTTY_POINT_ACTIVE, GHOSTTY_POINT_VIEWPORT, @@ -1130,6 +1137,11 @@ bool ghostty_surface_read_text(ghostty_surface_t, ghostty_text_s*); void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); +typedef void* ghostty_ax_context_t; +ghostty_ax_context_t ghostty_surface_ax_context_new(ghostty_surface_t); +void ghostty_surface_ax_context_free(ghostty_ax_context_t); +bool ghostty_ax_context_info(ghostty_ax_context_t, ghostty_ax_text_s*); + #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index 99d6e39ba..f7c51a4c2 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -37,13 +37,13 @@ struct GetTerminalDetailsIntent: AppIntent { case .workingDirectory: return .result(value: terminal.workingDirectory) case .allContents: guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } - return .result(value: view.cachedScreenContents.get()) + return .result(value: view.screenContents) case .selectedText: guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } return .result(value: view.accessibilitySelectedText()) case .visibleText: guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } - return .result(value: view.cachedVisibleContents.get()) + return .result(value: view.visibleContents) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 060b7990b..2ae655187 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -240,8 +240,28 @@ extension Ghostty { private var titleFromTerminal: String? // The cached contents of the screen. - private(set) var cachedScreenContents: CachedValue - private(set) var cachedVisibleContents: CachedValue + /// Full screen text, derived from the cached accessibility context. + var screenContents: String { cachedScreenTextInfo.get().text } + + /// Visible viewport text, extracted from the cached accessibility context. + var visibleContents: String { + let info = cachedScreenTextInfo.get() + return String(info.text[info.viewportRange]) + } + + /// Full screen text (scrollback + active area) with the viewport's + /// character range, used by accessibility overrides. + struct ScreenTextInfo { + static let empty = ScreenTextInfo( + text: "", viewportRange: "".startIndex..<"".endIndex) + + let text: String + let viewportRange: Range + + /// Length in UTF-16 code units, matching NSRange semantics. + var utf16Length: Int { (text as NSString).length } + } + private(set) var cachedScreenTextInfo: CachedValue /// Event monitor (see individual events for why) private var eventMonitor: Any? @@ -263,54 +283,42 @@ extension Ghostty { // 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)) { "" } - self.cachedVisibleContents = self.cachedScreenContents + self.cachedScreenTextInfo = .init(duration: .milliseconds(500)) { + ScreenTextInfo.empty + } // 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: NSRect(x: 0, y: 0, width: 800, height: 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) - } - cachedVisibleContents = .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_VIEWPORT, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0), - bottom_right: ghostty_point_s( - tag: GHOSTTY_POINT_VIEWPORT, - 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) + // Single cache for screen text with viewport range. Creates a Zig + // accessibility context that extracts both the full text and viewport + // byte offsets in one call (single PinMap build). + cachedScreenTextInfo = CachedValue(duration: .milliseconds(500)) { [weak self] in + guard let self else { return .empty } + guard let surface = self.surface else { return .empty } + + guard let ctxRaw = ghostty_surface_ax_context_new(surface) else { return .empty } + defer { ghostty_surface_ax_context_free(ctxRaw) } + + var axText = ghostty_ax_text_s() + guard ghostty_ax_context_info(ctxRaw, &axText) else { return .empty } + let text = String(cString: axText.text) + + // Convert UTF-8 byte offsets to String.Index range. + let vpStart = Int(axText.viewport_start) + let vpEnd = Int(axText.viewport_end) + + let utf8 = text.utf8 + let clampedStart = min(vpStart, utf8.count) + let clampedEnd = max(clampedStart, min(vpEnd, utf8.count)) + + let startIdx = utf8.index(utf8.startIndex, offsetBy: clampedStart) + let endIdx = utf8.index(utf8.startIndex, offsetBy: clampedEnd) + + return ScreenTextInfo( + text: text, viewportRange: startIdx.. Any? { - return cachedScreenContents.get() + return cachedScreenTextInfo.get().text } /// 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() } @@ -2207,7 +2213,7 @@ extension Ghostty.SurfaceView { override func accessibilitySelectedText() -> String? { guard let surface = self.surface else { return nil } - // Attempt to read the selection + // 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) } @@ -2217,31 +2223,81 @@ extension Ghostty.SurfaceView { } /// Returns the number of characters in the terminal content. - /// This helps assistive technologies understand the size of the content. + /// We use NSString.length (UTF-16 code unit count) rather than String.count + /// (grapheme cluster count) because NSRange — used by all accessibility APIs — + /// operates on UTF-16 offsets. override func accessibilityNumberOfCharacters() -> Int { - let content = cachedScreenContents.get() - return content.count + return cachedScreenTextInfo.get().utf16Length } - /// Returns the visible character range for the terminal. - /// For terminals, we typically show all content as visible. + /// Returns only the visible viewport range within the full text, + /// not the entire scrollback buffer. override func accessibilityVisibleCharacterRange() -> NSRange { - let content = cachedScreenContents.get() - return NSRange(location: 0, length: content.count) + let info = cachedScreenTextInfo.get() + return NSRange(info.viewportRange, in: info.text) } /// Returns the line number for a given character index. - /// This helps assistive technologies navigate by line. + /// The `index` parameter is a UTF-16 code unit offset (NSRange convention), + /// so we must convert it to a Swift String.Index before slicing. + /// + /// Note: counts `\n`-delimited lines, not visual screen rows. + /// Soft-wrapped lines are treated as a single line. Accurate + /// visual-line support requires grid mapping. override func accessibilityLine(for index: Int) -> Int { - let content = cachedScreenContents.get() - let substring = String(content.prefix(index)) - return substring.components(separatedBy: .newlines).count - 1 + let info = cachedScreenTextInfo.get() + // AX callers may pass out-of-range values (including -1 / NSNotFound), + // so clamp defensively before building NSRange. + let clampedIndex = max(0, min(index, info.utf16Length)) + let nsRange = NSRange(location: 0, length: clampedIndex) + guard let swiftRange = Range(nsRange, in: info.text) else { return 0 } + return info.text[.. NSRange { + let info = cachedScreenTextInfo.get() + let nsContent = info.text as NSString + let length = nsContent.length + + var currentLine = 0 + var lineStart = 0 + var i = 0 + while i < length { + if currentLine == line { + break + } + if nsContent.character(at: i) == 0x0A { // '\n' + currentLine += 1 + lineStart = i + 1 + } + i += 1 + } + + guard currentLine == line else { + return NSRange(location: NSNotFound, length: 0) + } + + var lineEnd = lineStart + while lineEnd < length && nsContent.character(at: lineEnd) != 0x0A { + lineEnd += 1 + } + + return NSRange(location: lineStart, length: lineEnd - lineStart) } /// 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() + let content = cachedScreenTextInfo.get().text guard let swiftRange = Range(range, in: content) else { return nil } return String(content[swiftRange]) } @@ -2259,7 +2315,7 @@ extension Ghostty.SurfaceView { var attributes: [NSAttributedString.Key: Any] = [:] - // Try to get the font from the surface + // 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() diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e..ed673283a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2009,6 +2009,24 @@ pub fn dumpTextLocked( }; } +// --------------------------------------------------------------- +// Accessibility helpers +// --------------------------------------------------------------- + +pub const AccessibilityContext = terminal.Screen.AccessibilityContext; + +/// Creates a pre-computed accessibility context (text + viewport +/// range). The context is self-contained and can be used without +/// holding the terminal mutex. +pub fn createAccessibilityContext( + self: *Surface, + alloc: Allocator, +) !*AccessibilityContext { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return self.io.terminal.screens.active.createAccessibilityContext(alloc); +} + /// 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 54d5472c6..1da382655 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1668,6 +1668,55 @@ pub const CAPI = struct { ptr.deinit(); } + // ghostty_ax_text_s — text info read from an AccessibilityContext. + // The text pointer is borrowed from the context; the caller must + // not free it separately. + const AXText = extern struct { + text: ?[*:0]const u8, + text_len: usize, + viewport_start: usize, + viewport_end: usize, + }; + + /// Creates a pre-computed accessibility context containing + /// the terminal text and viewport range. The context is + /// self-contained and can be used for subsequent queries + /// without holding the terminal mutex. + /// Free with ghostty_surface_ax_context_free. + export fn ghostty_surface_ax_context_new( + surface: *Surface, + ) ?*anyopaque { + const ctx = surface.core_surface.createAccessibilityContext( + global.alloc, + ) catch |err| { + log.warn("error creating accessibility context err={}", .{err}); + return null; + }; + return ctx; + } + + export fn ghostty_surface_ax_context_free(ctx_raw: *anyopaque) void { + const ctx: *CoreSurface.AccessibilityContext = @ptrCast(@alignCast(ctx_raw)); + ctx.deinit(); + } + + /// Reads text and viewport info from a pre-built context. + /// The text pointer is borrowed — it remains valid until the + /// context is freed. + export fn ghostty_ax_context_info( + ctx_raw: *anyopaque, + result: *AXText, + ) bool { + const ctx: *const CoreSurface.AccessibilityContext = @ptrCast(@alignCast(ctx_raw)); + result.* = .{ + .text = ctx.text.ptr, + .text_len = ctx.text.len, + .viewport_start = ctx.viewport_start, + .viewport_end = ctx.viewport_end, + }; + return true; + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_refresh(surface: *Surface) void { surface.refresh(); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ea8ca1445..4f0a304a8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2494,6 +2494,164 @@ pub fn selectionString( return text; } +// --------------------------------------------------------------- +// Accessibility helpers +// --------------------------------------------------------------- + +/// Pre-computed accessibility snapshot containing the full terminal +/// text and the byte offsets delimiting the visible viewport. +/// Contains only owned data — no Pins or page pointers — so it is +/// safe to cache and use without holding the terminal mutex. +pub const AccessibilityContext = struct { + alloc: Allocator, + + /// The terminal text (all scrollback + active area). + text: [:0]const u8, + + /// Byte offsets within `text` that delimit the visible viewport. + viewport_start: usize, + viewport_end: usize, + + pub fn deinit(self: *AccessibilityContext) void { + self.alloc.free(self.text); + self.alloc.destroy(self); + } +}; + +/// Builds an AccessibilityContext: generates the terminal text with +/// a PinMap, computes viewport byte boundaries, then discards the +/// PinMap. The returned context is fully self-contained and can be +/// used without the terminal mutex. +pub fn createAccessibilityContext( + self: *Screen, + alloc: Allocator, +) !*AccessibilityContext { + // Empty screen fast path. + const screen_tl = self.pages.getTopLeft(.screen); + const screen_br = self.pages.getBottomRight(.screen) orelse { + return try self.createEmptyContext(alloc); + }; + + const sel = Selection.init(screen_tl, screen_br, false); + + // Generate text with a pin map so we can locate viewport boundaries. + var string_map: StringMap = undefined; + const text = try self.selectionString(alloc, .{ + .sel = sel, + .trim = false, + .map = &string_map, + }); + errdefer alloc.free(text); + defer { + alloc.free(string_map.string); + alloc.free(string_map.map); + } + + // Build a node → cumulative-row-offset lookup so we can + // convert any Pin to an absolute screen row in O(1). + var node_offsets = std.AutoHashMap(*PageList.List.Node, usize).init(alloc); + defer node_offsets.deinit(); + { + var total: usize = 0; + var it = self.pages.pages.first; + while (it) |node| : (it = node.next) { + try node_offsets.put(node, total); + total += node.data.size.rows; + } + } + + // Determine viewport boundaries by scanning the pin map. A binary + // search is possible (rows are monotonically non-decreasing) but + // wouldn't change overall complexity since building the PinMap is + // already O(n). + const vp_tl = self.pages.getTopLeft(.viewport); + const vp_br = self.pages.getBottomRight(.viewport); + + var vp_start: usize = text.len; + var vp_end: usize = text.len; + + if (vp_br != null) { + const vp_tl_screen = self.pages.pointFromPin(.screen, vp_tl); + const vp_br_screen = self.pages.pointFromPin(.screen, vp_br.?); + + if (vp_tl_screen != null and vp_br_screen != null) { + const vp_tl_row = vp_tl_screen.?.coord().y; + const vp_br_row = vp_br_screen.?.coord().y; + + for (string_map.map, 0..) |pin, i| { + const row_offset = node_offsets.get(pin.node) orelse continue; + const abs_row = row_offset + pin.y; + + if (abs_row < vp_tl_row) continue; + // We'd expect the viewport to always start at column 0, + // but guard against the general case. + if (abs_row == vp_tl_row and pin.x < vp_tl.x) continue; + + if (abs_row > vp_br_row or (abs_row == vp_br_row and pin.x > vp_br.?.x)) { + vp_end = i; + break; + } + + if (vp_start > i) vp_start = i; + } + } + } + + if (vp_start >= string_map.map.len) vp_start = text.len; + if (vp_end >= string_map.map.len) vp_end = text.len; + + const ctx = try alloc.create(AccessibilityContext); + ctx.* = .{ + .alloc = alloc, + .text = text, + .viewport_start = vp_start, + .viewport_end = vp_end, + }; + return ctx; +} + +fn createEmptyContext( + self: *Screen, + alloc: Allocator, +) !*AccessibilityContext { + _ = self; + const ctx = try alloc.create(AccessibilityContext); + ctx.* = .{ + .alloc = alloc, + .text = try alloc.dupeZ(u8, ""), + .viewport_start = 0, + .viewport_end = 0, + }; + return ctx; +} + +/// Convenience wrapper that returns the text and viewport range +/// without exposing the full context object to callers that only +/// need these values. +pub const AccessibilityText = struct { + text: [:0]const u8, + viewport_start: usize, + viewport_end: usize, + + pub fn deinit(self: *AccessibilityText, alloc: Allocator) void { + alloc.free(self.text); + } +}; + +pub fn accessibilityText( + self: *Screen, + alloc: Allocator, +) !AccessibilityText { + const ctx = try self.createAccessibilityContext(alloc); + defer alloc.destroy(ctx); + // Keep the text alive; only free the context wrapper. + return .{ + .text = ctx.text, + .viewport_start = ctx.viewport_start, + .viewport_end = ctx.viewport_end, + }; +} + pub const SelectLine = struct { /// The pin of some part of the line to select. pin: Pin, @@ -10350,3 +10508,131 @@ test "Screen: promptClickMove click right of input cursor on last char" { try testing.expectEqual(@as(usize, 1), result.right); try testing.expectEqual(@as(usize, 0), result.left); } + +// --------------------------------------------------------------- +// Accessibility tests +// --------------------------------------------------------------- + +test "Screen: accessibilityText basic no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello\nworld\nfoo"); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + try testing.expectEqualStrings("hello\nworld\nfoo", result.text); + // No scrollback: viewport == entire screen. + try testing.expectEqual(@as(usize, 0), result.viewport_start); + try testing.expectEqual(result.text.len, result.viewport_end); +} + +test "Screen: accessibilityText scrollback viewport at bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + defer s.deinit(); + // Write 5 lines into a 3-row screen: lines 0-1 go to scrollback, + // lines 2-4 are in the active/viewport area. + try s.testWriteString("line0\nline1\nline2\nline3\nline4"); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + // Full text includes all 5 lines. + try testing.expectEqualStrings("line0\nline1\nline2\nline3\nline4", result.text); + + // Viewport should cover lines 2-4 (the bottom 3 rows). + const vp_text = result.text[result.viewport_start..result.viewport_end]; + try testing.expectEqualStrings("line2\nline3\nline4", vp_text); +} + +test "Screen: accessibilityText scrollback scrolled up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + defer s.deinit(); + try s.testWriteString("line0\nline1\nline2\nline3\nline4"); + + // Scroll up by 2 rows so viewport shows lines 0-2. + s.scroll(.{ .delta_row = -2 }); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + try testing.expectEqualStrings("line0\nline1\nline2\nline3\nline4", result.text); + + const vp_text = result.text[result.viewport_start..result.viewport_end]; + // The trailing newline is included because the newline between + // the last visible row and the first non-visible row is still + // mapped to a pin within the viewport's row range. + try testing.expectEqualStrings("line0\nline1\nline2\n", vp_text); +} + +test "Screen: accessibilityText wide characters" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("A⚡B"); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + try testing.expectEqualStrings("A⚡B", result.text); + try testing.expectEqual(@as(usize, 0), result.viewport_start); + try testing.expectEqual(result.text.len, result.viewport_end); +} + +test "Screen: accessibilityText soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + // 5 columns, so "1234567890" soft-wraps across 2 rows. + var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1234567890"); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + // Soft-wrapped lines do NOT produce a '\n' in the output. + try testing.expectEqualStrings("1234567890", result.text); +} + +test "Screen: accessibilityText empty screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + try testing.expectEqualStrings("", result.text); + try testing.expectEqual(@as(usize, 0), result.viewport_start); + try testing.expectEqual(@as(usize, 0), result.viewport_end); +} + +test "Screen: accessibilityText partially filled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello"); + + var result = try s.accessibilityText(alloc); + defer result.deinit(alloc); + + try testing.expectEqualStrings("hello", result.text); + try testing.expectEqual(@as(usize, 0), result.viewport_start); + try testing.expectEqual(result.text.len, result.viewport_end); +}