diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 34e194fa6817..c036a1e3d2e9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -557994,6 +557994,65 @@ } } } + }, + "terminal.accessibility.contentArea.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal content area" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "منطقة محتوى الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminalinhaltsbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Área de contenido de la terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zone de contenu du terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルコンテンツ領域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 콘텐츠 영역" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端内容区域" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機內容區域" + } + } + } } }, "version": "1.0" diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index ab881fd4f9c8..555b2d6831d8 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -151,6 +151,17 @@ final class CmuxMainWindow: NSWindow { workspaceSwitchSignposts.end(switchInterval) } + /// Returns the focused terminal surface directly so AX clients can read + /// terminal selection attributes without stopping at the window element. + override var accessibilityFocusedUIElement: Any? { + if let terminalView = firstResponder?.cmuxTerminalFocusOwningGhosttyView(), + terminalView.window === self, + terminalView.isAccessibilityElement() { + return terminalView + } + return super.accessibilityFocusedUIElement + } + /// No content may resize this window past the attached display union. The content view /// hosts AppKit subtrees whose subviews carry REQUIRED autoresizing-mask /// constraints, and if any of them is ever laid out oversized, AppKit diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 271ebd5ebcdd..8bc02b82c49a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3772,6 +3772,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var _renderedFrameFlushScheduled = false private var _pendingRenderedFrameDeliveryReasons: TerminalRenderedFrameDeliveryReasons = [] + private var accessibilityScreenContentsCache: (value: String, expiresAt: CFTimeInterval)? /// Pane-local frame demand lets a terminal-specific consumer observe a /// late render without enabling notifications on every terminal surface. let localRenderedFrameNotificationDemand = RenderDemandCounter() @@ -6325,14 +6326,88 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func accessibilityHelp() -> String? { - "Terminal content area" + String(localized: "terminal.accessibility.contentArea.help", defaultValue: "Terminal content area") + } + + override func isAccessibilityFocused() -> Bool { + guard let window, window.isKeyWindow else { return false } + return window.firstResponder?.cmuxTerminalFocusOwningGhosttyView() === self } override func accessibilityValue() -> Any? { - // We don't keep a full terminal text snapshot in this layer. - // Expose selected text when available; otherwise provide an empty value - // so AX clients still treat this as an editable text area. - accessibilitySelectedText() ?? "" + accessibilityScreenContents() + } + + override func accessibilityNumberOfCharacters() -> Int { + accessibilityScreenContents().utf16.count + } + + override func accessibilityVisibleCharacterRange() -> NSRange { + let content = accessibilityScreenContents() + return NSRange(location: 0, length: content.utf16.count) + } + + override func accessibilityLine(for index: Int) -> Int { + let content = accessibilityScreenContents() + let prefix = String(decoding: content.utf16.prefix(max(0, index)), as: UTF16.self) + return prefix.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + } + + override func accessibilityString(for range: NSRange) -> String? { + let content = accessibilityScreenContents() + guard let swiftRange = Range(range, in: content) else { return nil } + return String(content[swiftRange]) + } + + override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { + guard let plainString = accessibilityString(for: range) else { return nil } + var attributes: [NSAttributedString.Key: Any] = [:] + if let surface, + let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + return NSAttributedString(string: plainString, attributes: attributes) + } + + private func accessibilityScreenContents() -> String { + let now = CACurrentMediaTime() + if let cached = accessibilityScreenContentsCache, cached.expiresAt > now { + return cached.value + } + guard let surface else { return "" } + + let selection = 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 + ) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { return "" } + defer { ghostty_surface_free_text(surface, &text) } + + let value: String + if let ptr = text.text, text.text_len > 0 { + let data = Data(bytes: ptr, count: Int(text.text_len)) + value = String(decoding: data, as: UTF8.self) + } else { + value = "" + } + accessibilityScreenContentsCache = (value: value, expiresAt: now + 0.5) + return value } override func setAccessibilityValue(_ value: Any?) { @@ -6427,6 +6502,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() + if result { + postAccessibilityFocusChanged() + } var shouldApplySurfaceFocus = false if result { imeConsumedKeyUps.removeAll() @@ -6540,6 +6618,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() + if result { + postAccessibilityFocusChanged() + } if result { imeConsumedKeyUps.removeAll() manualNamedKeyConsumedKeyUps.removeAll() @@ -6561,6 +6642,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return result } + private func postAccessibilityFocusChanged() { + guard let window else { return } + NSAccessibility.post(element: window, notification: .focusedUIElementChanged) + NSAccessibility.post(element: NSApp, notification: .focusedUIElementChanged) + } + // For NSTextInputClient - accumulates text during key events private(set) var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index b63914c81fce..cbceb369e984 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -974,6 +974,96 @@ final class GhosttyPasteboardHelperTests: XCTestCase { } } +@MainActor +final class TerminalAccessibilityRegressionTests: XCTestCase { + private var surfaces: [TerminalSurface] = [] + + override func tearDown() { + for surface in surfaces { + surface.hostedView.removeFromSuperview() + surface.teardownSurface() + } + surfaces.removeAll() + super.tearDown() + } + + func testAccessibilityValueExposesRenderedTerminalContent() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + surfaces.append(surface) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let hostedView = surface.hostedView + hostedView.frame = window.contentView?.bounds ?? .zero + hostedView.autoresizingMask = [.width, .height] + window.contentView?.addSubview(hostedView) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + window.contentView?.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + waitUntil(timeout: 5) { surface.surface != nil }, + "Expected a live Ghostty surface before checking accessibility content" + ) + + let marker = "cmux-ax-content-\(UUID().uuidString)" + XCTAssertTrue(surface.sendText("printf '\\n\(marker)\\n'\n")) + + guard let surfaceView = hostedView.surfaceView else { + XCTFail("Expected the hosted Ghostty view") + return + } + XCTAssertTrue( + waitUntil(timeout: 5) { + (surfaceView.accessibilityValue() as? String)?.contains(marker) == true + }, + "AXValue should contain text rendered by the terminal" + ) + } + + func testMainWindowAccessibilityFocusResolvesFocusedTerminalView() { + let window = CmuxMainWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let terminalView = GhosttyNSView(frame: window.contentView?.bounds ?? .zero) + window.contentView?.addSubview(terminalView) + window.makeKeyAndOrderFront(nil) + + XCTAssertTrue(window.makeFirstResponder(terminalView)) + XCTAssertTrue(window.accessibilityFocusedUIElement as AnyObject? === terminalView) + } + + private func waitUntil(timeout: TimeInterval, condition: () -> Bool) -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + return condition() + } +} + @MainActor final class TerminalOffscreenStartupTests: XCTestCase { #if DEBUG