Skip to content

Implement Missing Accessibility APIs #116

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// File.swift
// CodeEditTextView
//
// Created by Khan Winter on 7/17/25.
//

import AppKit

extension Array where Element == CGRect {
/// Returns a rect object that contains all of the rects in this array.
/// Returns `.zero` if the array is empty.
/// - Returns: The minimum rectangle that contains all rectangles in this array.
func boundingRect() -> CGRect {
guard !self.isEmpty else { return .zero }
let minX = self.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0
let minY = self.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0
let max = self.max(by: { $0.maxY < $1.maxY }) ?? .zero
let origin = CGPoint(x: minX, y: minY)
let size = CGSize(width: max.maxX - minX, height: max.maxY - minY)
return CGRect(origin: origin, size: size)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public extension TextLayoutManager {
}

if lastAttachment.range.max > originalPosition.position.range.max,
var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
newPosition = TextLineStorage<TextLine>.TextLinePosition(
data: newPosition.data,
range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,7 @@ extension TextSelectionManager {
context.setFillColor(fillColor)

let fillRects = getFillRects(in: rect, for: textSelection)

let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0
let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0
let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero
let origin = CGPoint(x: minX, y: minY)
let size = CGSize(width: max.maxX - minX, height: max.maxY - minY)
textSelection.boundingRect = CGRect(origin: origin, size: size)
textSelection.boundingRect = fillRects.boundingRect()

context.fill(fillRects)
context.restoreGState()
Expand Down
73 changes: 57 additions & 16 deletions Sources/CodeEditTextView/TextView/TextView+Accessibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import AppKit

/// # Notes
///
/// This implementation considers the entire document as one element, ignoring all subviews and lines.
/// ~~This implementation considers the entire document as one element, ignoring all subviews and lines.
/// Another idea would be to make each line fragment an accessibility element, with options for navigating through
/// lines from there. The text view would then only handle text input, and lines would handle reading out useful data
/// to the user.
/// More research needs to be done for the best option here.
/// More research needs to be done for the best option here.~~
///
/// Consider that the system has access to the ``TextView/accessibilityVisibleCharacterRange`` and
/// ``TextView/accessibilityString(for:)`` methods. These can combine to allow an accessibility system to efficiently
/// query the text view's contents. Adding accessibility elements to line fragments would require hit testing them,
/// which will cause performance degradation.
extension TextView {
override open func isAccessibilityElement() -> Bool {
true
Expand All @@ -27,6 +32,11 @@ extension TextView {
isFirstResponder
}

override open func setAccessibilityFocused(_ accessibilityFocused: Bool) {
guard !isFirstResponder else { return }
window?.makeFirstResponder(self)
}

override open func accessibilityLabel() -> String? {
"Text Editor"
}
Expand All @@ -48,21 +58,26 @@ extension TextView {
}

override open func accessibilityString(for range: NSRange) -> String? {
textStorage.substring(
guard documentRange.intersection(range) == range else {
return nil
}

return textStorage.substring(
from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range)
)
}

// MARK: Selections

override open func accessibilitySelectedText() -> String? {
guard let selection = selectionManager
.textSelections
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
.first else {
let selectedRange = accessibilitySelectedTextRange()
guard selectedRange != .notFound else {
return nil
}
let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range)
if selectedRange.isEmpty {
return ""
}
let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selectedRange)
return textStorage.substring(from: range)
}

Expand All @@ -71,7 +86,10 @@ extension TextView {
.textSelections
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
.first else {
return .zero
return .notFound
}
if selection.range.isEmpty {
return selection.range
}
return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range)
}
Expand All @@ -83,12 +101,10 @@ extension TextView {
}

override open func accessibilityInsertionPointLineNumber() -> Int {
guard let selection = selectionManager
.textSelections
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
.first,
let linePosition = layoutManager.textLineForOffset(selection.range.location) else {
return 0
let selectedRange = accessibilitySelectedTextRange()
guard selectedRange != .notFound,
let linePosition = layoutManager.textLineForOffset(selectedRange.location) else {
return -1
}
return linePosition.index
}
Expand Down Expand Up @@ -122,6 +138,31 @@ extension TextView {
}

override open func accessibilityRange(for index: Int) -> NSRange {
textStorage.mutableString.rangeOfComposedCharacterSequence(at: index)
guard index < documentRange.length else { return .notFound }
return textStorage.mutableString.rangeOfComposedCharacterSequence(at: index)
}

override open func accessibilityVisibleCharacterRange() -> NSRange {
visibleTextRange ?? .notFound
}

/// The line index for a given character offset.
override open func accessibilityLine(for index: Int) -> Int {
guard index <= textStorage.length,
let textLine = layoutManager.textLineForOffset(index) else {
return -1
}
return textLine.index
}

override open func accessibilityFrame(for range: NSRange) -> NSRect {
guard documentRange.intersection(range) == range else {
return .zero
}
if range.isEmpty {
return .zero
}
let rects = layoutManager.rectsFor(range: range)
return rects.boundingRect()
}
}
Loading