Skip to content

Commit f576370

Browse files
Implement Missing Accessibility APIs (#116)
### Description Fills out the last few missing accessibility methods for the text view. Adds unit tests for all implemented accessibility methods. ### Related Issues * closes #115 * closes #114 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots N/A
1 parent d65c2a4 commit f576370

File tree

5 files changed

+381
-24
lines changed

5 files changed

+381
-24
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// File.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 7/17/25.
6+
//
7+
8+
import AppKit
9+
10+
extension Array where Element == CGRect {
11+
/// Returns a rect object that contains all of the rects in this array.
12+
/// Returns `.zero` if the array is empty.
13+
/// - Returns: The minimum rectangle that contains all rectangles in this array.
14+
func boundingRect() -> CGRect {
15+
guard !self.isEmpty else { return .zero }
16+
let minX = self.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0
17+
let minY = self.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0
18+
let max = self.max(by: { $0.maxY < $1.maxY }) ?? .zero
19+
let origin = CGPoint(x: minX, y: minY)
20+
let size = CGSize(width: max.maxX - minX, height: max.maxY - minY)
21+
return CGRect(origin: origin, size: size)
22+
}
23+
}

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ public extension TextLayoutManager {
196196
}
197197

198198
if lastAttachment.range.max > originalPosition.position.range.max,
199-
var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
199+
let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) {
200200
newPosition = TextLineStorage<TextLine>.TextLinePosition(
201201
data: newPosition.data,
202202
range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max),

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,7 @@ extension TextSelectionManager {
8282
context.setFillColor(fillColor)
8383

8484
let fillRects = getFillRects(in: rect, for: textSelection)
85-
86-
let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0
87-
let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0
88-
let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero
89-
let origin = CGPoint(x: minX, y: minY)
90-
let size = CGSize(width: max.maxX - minX, height: max.maxY - minY)
91-
textSelection.boundingRect = CGRect(origin: origin, size: size)
85+
textSelection.boundingRect = fillRects.boundingRect()
9286

9387
context.fill(fillRects)
9488
context.restoreGState()

Sources/CodeEditTextView/TextView/TextView+Accessibility.swift

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ import AppKit
99

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

35+
override open func setAccessibilityFocused(_ accessibilityFocused: Bool) {
36+
guard !isFirstResponder else { return }
37+
window?.makeFirstResponder(self)
38+
}
39+
3040
override open func accessibilityLabel() -> String? {
3141
"Text Editor"
3242
}
@@ -48,21 +58,26 @@ extension TextView {
4858
}
4959

5060
override open func accessibilityString(for range: NSRange) -> String? {
51-
textStorage.substring(
61+
guard documentRange.intersection(range) == range else {
62+
return nil
63+
}
64+
65+
return textStorage.substring(
5266
from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range)
5367
)
5468
}
5569

5670
// MARK: Selections
5771

5872
override open func accessibilitySelectedText() -> String? {
59-
guard let selection = selectionManager
60-
.textSelections
61-
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
62-
.first else {
73+
let selectedRange = accessibilitySelectedTextRange()
74+
guard selectedRange != .notFound else {
6375
return nil
6476
}
65-
let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range)
77+
if selectedRange.isEmpty {
78+
return ""
79+
}
80+
let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selectedRange)
6681
return textStorage.substring(from: range)
6782
}
6883

@@ -71,7 +86,10 @@ extension TextView {
7186
.textSelections
7287
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
7388
.first else {
74-
return .zero
89+
return .notFound
90+
}
91+
if selection.range.isEmpty {
92+
return selection.range
7593
}
7694
return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range)
7795
}
@@ -83,12 +101,10 @@ extension TextView {
83101
}
84102

85103
override open func accessibilityInsertionPointLineNumber() -> Int {
86-
guard let selection = selectionManager
87-
.textSelections
88-
.sorted(by: { $0.range.lowerBound < $1.range.lowerBound })
89-
.first,
90-
let linePosition = layoutManager.textLineForOffset(selection.range.location) else {
91-
return 0
104+
let selectedRange = accessibilitySelectedTextRange()
105+
guard selectedRange != .notFound,
106+
let linePosition = layoutManager.textLineForOffset(selectedRange.location) else {
107+
return -1
92108
}
93109
return linePosition.index
94110
}
@@ -122,6 +138,31 @@ extension TextView {
122138
}
123139

124140
override open func accessibilityRange(for index: Int) -> NSRange {
125-
textStorage.mutableString.rangeOfComposedCharacterSequence(at: index)
141+
guard index < documentRange.length else { return .notFound }
142+
return textStorage.mutableString.rangeOfComposedCharacterSequence(at: index)
143+
}
144+
145+
override open func accessibilityVisibleCharacterRange() -> NSRange {
146+
visibleTextRange ?? .notFound
147+
}
148+
149+
/// The line index for a given character offset.
150+
override open func accessibilityLine(for index: Int) -> Int {
151+
guard index <= textStorage.length,
152+
let textLine = layoutManager.textLineForOffset(index) else {
153+
return -1
154+
}
155+
return textLine.index
156+
}
157+
158+
override open func accessibilityFrame(for range: NSRange) -> NSRect {
159+
guard documentRange.intersection(range) == range else {
160+
return .zero
161+
}
162+
if range.isEmpty {
163+
return .zero
164+
}
165+
let rects = layoutManager.rectsFor(range: range)
166+
return rects.boundingRect()
126167
}
127168
}

0 commit comments

Comments
 (0)