VoiceOver reads the editor as shown; labelled tabs, cards and rows !107

merged merged by cmc on 2026-10-06 07:23 UTC · krz/orgstar:accessibility into main

8 files changed, +285 −0

Layout: unified · split

Sources/OrgEditorAppKit/Accessibility.swift added +189
@@ -0,0 +1,189 @@
1#if os(macOS)
2import AppKit
3
4/// The text as shown, for VoiceOver: without hidden markup (link brackets and targets, hidden
5/// emphasis markers and stars), with pretty entities and shrunk table cells as they're drawn.
6/// Folded text is still read, so the whole file can be. `model` maps each UTF-16 unit of
7/// `string` to the buffer.
8struct AccessibleText {
9 let string: String
10 /// Buffer offset of each unit of `string`, then the buffer's length.
11 let model: [Int]
12
13 init(_ storage: NSAttributedString) {
14 let ns = storage.string as NSString
15 let whole = NSRange(location: 0, length: ns.length)
16 // What isn't read: markup drawn at no size. Entities read as drawn.
17 var skipped: [Range<Int>] = []
18 storage.enumerateAttribute(.font, in: whole) { value, run, _ in
19 if let font = value as? NSFont, font.pointSize < 0.1 { skipped.append(run.location..<NSMaxRange(run)) }
20 }
21 var displays: [(at: Int, text: String)] = []
22 storage.enumerateAttribute(.orgEntityDisplay, in: whole) { value, run, _ in
23 if let text = value as? String { displays.append((run.location, text)) }
24 }
25 skipped.sort { $0.lowerBound < $1.lowerBound }
26 var units = [unichar](repeating: 0, count: ns.length)
27 units.withUnsafeMutableBufferPointer { ns.getCharacters($0.baseAddress!, range: whole) }
28 var outUnits: [unichar] = []
29 var model: [Int] = []
30 outUnits.reserveCapacity(ns.length)
31 model.reserveCapacity(ns.length + 1)
32 var next = 0
33 var display = 0
34 func copy(_ from: Int, _ to: Int) {
35 var p = from
36 while p < to {
37 while display < displays.count, displays[display].at < p { display += 1 }
38 let stop = display < displays.count ? min(to, displays[display].at) : to
39 outUnits.append(contentsOf: units[p..<stop])
40 model.append(contentsOf: p..<stop)
41 p = stop
42 if p < to {
43 for unit in displays[display].text.utf16 {
44 outUnits.append(unit)
45 model.append(p)
46 }
47 p += 1
48 display += 1
49 }
50 }
51 }
52 for skip in skipped where skip.upperBound > next {
53 if skip.lowerBound > next { copy(next, skip.lowerBound) }
54 next = max(next, skip.upperBound)
55 }
56 copy(next, ns.length)
57 model.append(ns.length)
58 string = String(utf16CodeUnits: outUnits, count: outUnits.count)
59 self.model = model
60 }
61
62 var length: Int { model.count - 1 }
63
64 /// The shown offset at or after buffer offset `offset`.
65 func visible(_ offset: Int) -> Int {
66 var low = 0
67 var high = length
68 while low < high {
69 let mid = (low + high) / 2
70 if model[mid] < offset { low = mid + 1 } else { high = mid }
71 }
72 return low
73 }
74
75 func visible(_ range: NSRange) -> NSRange {
76 let start = visible(range.location)
77 return NSRange(location: start, length: max(0, visible(NSMaxRange(range)) - start))
78 }
79
80 /// The buffer range a shown range covers.
81 func buffer(_ range: NSRange) -> NSRange {
82 let start = model[min(max(0, range.location), length)]
83 let endIndex = min(max(0, NSMaxRange(range)), length)
84 let end = endIndex > range.location && endIndex > 0 ? model[endIndex - 1] + 1 : model[endIndex]
85 return NSRange(location: start, length: max(0, end - start))
86 }
87
88 /// Shown lines: line `n` is the text between the n-th and (n+1)-th newline.
89 func line(for index: Int) -> Int {
90 let ns = string as NSString
91 var line = 0
92 var p = 0
93 while p < min(index, ns.length) {
94 let next = ns.range(of: "\n", range: NSRange(location: p, length: min(index, ns.length) - p))
95 guard next.location != NSNotFound else { break }
96 line += 1
97 p = next.location + 1
98 }
99 return line
100 }
101
102 func range(forLine line: Int) -> NSRange {
103 let ns = string as NSString
104 var start = 0
105 for _ in 0..<line {
106 let next = ns.range(of: "\n", range: NSRange(location: start, length: ns.length - start))
107 guard next.location != NSNotFound else { return NSRange(location: ns.length, length: 0) }
108 start = next.location + 1
109 }
110 return ns.lineRange(for: NSRange(location: start, length: 0))
111 }
112}
113
114extension OrgTextView {
115 private var shown: AccessibleText? { accessibleText?() }
116
117 override func accessibilityValue() -> String? {
118 shown?.string ?? super.accessibilityValue()
119 }
120
121 override func accessibilityNumberOfCharacters() -> Int {
122 shown?.length ?? super.accessibilityNumberOfCharacters()
123 }
124
125 override func accessibilitySelectedTextRange() -> NSRange {
126 guard let shown else { return super.accessibilitySelectedTextRange() }
127 return shown.visible(selectedRange())
128 }
129
130 override func setAccessibilitySelectedTextRange(_ range: NSRange) {
131 guard let shown else { return super.setAccessibilitySelectedTextRange(range) }
132 setSelectedRange(shown.buffer(range))
133 }
134
135 override func accessibilitySelectedText() -> String? {
136 guard let shown else { return super.accessibilitySelectedText() }
137 return (shown.string as NSString).substring(with: shown.visible(selectedRange()))
138 }
139
140 override func accessibilityVisibleCharacterRange() -> NSRange {
141 guard let shown else { return super.accessibilityVisibleCharacterRange() }
142 return shown.visible(super.accessibilityVisibleCharacterRange())
143 }
144
145 override func accessibilityString(for range: NSRange) -> String? {
146 guard let shown else { return super.accessibilityString(for: range) }
147 let ns = shown.string as NSString
148 guard range.location >= 0, NSMaxRange(range) <= ns.length else { return nil }
149 return ns.substring(with: range)
150 }
151
152 override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
153 accessibilityString(for: range).map { NSAttributedString(string: $0) }
154 }
155
156 override func accessibilityLine(for index: Int) -> Int {
157 shown?.line(for: index) ?? super.accessibilityLine(for: index)
158 }
159
160 override func accessibilityInsertionPointLineNumber() -> Int {
161 guard let shown else { return super.accessibilityInsertionPointLineNumber() }
162 return shown.line(for: shown.visible(selectedRange().location))
163 }
164
165 override func accessibilityRange(forLine line: Int) -> NSRange {
166 shown?.range(forLine: line) ?? super.accessibilityRange(forLine: line)
167 }
168
169 override func accessibilityRange(for index: Int) -> NSRange {
170 guard let shown else { return super.accessibilityRange(for: index) }
171 return (shown.string as NSString).rangeOfComposedCharacterSequence(at: min(index, max(0, shown.length - 1)))
172 }
173
174 override func accessibilityStyleRange(for index: Int) -> NSRange {
175 accessibilityRange(for: index)
176 }
177
178 override func accessibilityFrame(for range: NSRange) -> NSRect {
179 guard let shown else { return super.accessibilityFrame(for: range) }
180 return super.accessibilityFrame(for: shown.buffer(range))
181 }
182
183 override func accessibilityRange(for point: NSPoint) -> NSRange {
184 guard let shown else { return super.accessibilityRange(for: point) }
185 let range = super.accessibilityRange(for: point)
186 return shown.visible(range)
187 }
188}
189#endif
Sources/OrgEditorAppKit/Keys.swift +2
@@ -14,6 +14,8 @@ public struct OptionAsMeta: OptionSet, Sendable {
1414final class OrgTextView: NSTextView {
1515 /// Returns true when the key was handled.
1616 var keyHandler: ((NSEvent) -> Bool)?
17 /// The text as shown, for accessibility.
18 var accessibleText: (() -> AccessibleText)?
1719 var onMouseDown: (() -> Void)?
1820 /// Each editor keeps its own undo history, not the window's.
1921 private let ownUndoManager = UndoManager()
Sources/OrgEditorAppKit/OrgEditor.swift +12
@@ -97,6 +97,10 @@ public final class OrgEditor: NSObject {
9797
9898 public let mode: Mode
9999 public var isOrg: Bool { mode == .org }
100 /// The text as shown for accessibility, and the storage generation it's for.
101 var accessibleCache: (storage: Int, text: AccessibleText)?
102 /// Bumped on every character or attribute change to the storage.
103 var storageGeneration = 0
100104 /// The open completion list, and the pause before one opens by itself.
101105 var completionPopup: CompletionPopup?
102106 var completionTimer: DispatchWorkItem?
@@ -125,6 +129,13 @@ public final class OrgEditor: NSObject {
125129 return true
126130 }
127131 orgTextView.onClick = { [weak self] offset in self?.click(at: offset) ?? false }
132 orgTextView.accessibleText = { [weak self] in
133 guard let self, let storage = self.textView.textStorage else { return AccessibleText(NSAttributedString()) }
134 if let cached = self.accessibleCache, cached.storage == self.storageGeneration { return cached.text }
135 let text = AccessibleText(storage)
136 self.accessibleCache = (self.storageGeneration, text)
137 return text
138 }
128139 orgTextView.onMouseDown = { [weak self] in
129140 self?.markActive = false
130141 self?.modalMouseDown()
@@ -1351,6 +1362,7 @@ extension OrgEditor: NSTextStorageDelegate {
13511362 public nonisolated func textStorage(
13521363 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
13531364 ) {
1365 MainActor.assumeIsolated { storageGeneration += 1 }
13541366 guard editedMask.contains(.editedCharacters) else { return }
13551367 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
13561368 }
Sources/Orgstar/BoardView.swift +9
@@ -152,6 +152,15 @@ struct BoardView: View {
152152 card(entry)
153153 .draggable(entry.id)
154154 .onTapGesture(count: 2) { jump(entry) }
155 .accessibilityElement(children: .combine)
156 .accessibilityAddTraits(.isButton)
157 .accessibilityAction { jump(entry) }
158 .accessibilityActions {
159 // Dragging between columns, for VoiceOver.
160 ForEach(keywords.filter { $0 != keyword }, id: \.self) { other in
161 Button("Move to \(other)") { setTodo(entry, other) }
162 }
163 }
155164 }
156165 }
157166 }
Sources/Orgstar/ColumnViewSheet.swift +5
@@ -35,6 +35,11 @@ struct ColumnViewSheet: View {
3535 }
3636 }
3737 .contentShape(Rectangle())
38 .accessibilityAddTraits(.isButton)
39 .accessibilityAction {
40 jump(row.start)
41 dismiss()
42 }
3843 .onTapGesture {
3944 jump(row.start)
4045 dismiss()
Sources/Orgstar/ContentView.swift +3
@@ -891,6 +891,8 @@ struct BufferTabs: View {
891891 Text((path as NSString).lastPathComponent)
892892 .lineLimit(1)
893893 .foregroundStyle(current ? .primary : .secondary)
894 .accessibilityAddTraits(current ? [.isButton, .isSelected] : .isButton)
895 .accessibilityAction { session.open(path) }
894896 Button {
895897 if path == session.path {
896898 if UnsavedChanges.resolve(session) { session.kill(path) }
@@ -904,6 +906,7 @@ struct BufferTabs: View {
904906 }
905907 .buttonStyle(.borderless)
906908 .help("Close")
909 .accessibilityLabel("Close \((path as NSString).lastPathComponent)" + (session.dirtyPaths.contains(path) ? ", unsaved" : ""))
907910 }
908911 .font(.callout)
909912 .padding(.horizontal, 12)
Sources/Orgstar/InspectorPane.swift +5
@@ -73,6 +73,8 @@ struct InspectorPane: View {
7373 }
7474 .contentShape(Rectangle())
7575 .onTapGesture { session.reveal(row.start) }
76 .accessibilityAddTraits(.isButton)
77 .accessibilityAction { session.reveal(row.start) }
7678 }
7779 }
7880 .font(.system(.callout, design: .monospaced))
@@ -105,6 +107,9 @@ struct InspectorPane: View {
105107 .fontWeight(entry.start == heading ? .semibold : .regular)
106108 .contentShape(Rectangle())
107109 .onTapGesture { session.reveal(entry.start) }
110 .accessibilityElement(children: .combine)
111 .accessibilityAddTraits(.isButton)
112 .accessibilityAction { session.reveal(entry.start) }
108113 }
109114 HStack {
110115 Text("Total").bold()
Tests/OrgEditorAppKitTests/AccessibilityTests.swift added +60
@@ -0,0 +1,60 @@
1import AppKit
2import Testing
3@testable import OrgEditorAppKit
4
5/// What VoiceOver reads: the text as shown, with ranges and lines in its terms.
6@MainActor
7struct AccessibilityTests {
8 let text = "* Top\nbody\n* Links\nSee [[https://x.org][the site]] now.\n"
9
10 @Test func readsTheTextAsShown() {
11 let h = Harness(text)
12 h.editor.showMarkup = false
13 h.caret(at: 0)
14 h.drainStyling()
15 let view = h.textView
16 #expect(view.accessibilityValue() == "* Top\nbody\n* Links\nSee the site now.\n")
17 #expect(view.accessibilityNumberOfCharacters() == 37)
18
19 // The caret after "the" in the link description.
20 let the = (text as NSString).range(of: "the site").location
21 h.caret(at: the + 3)
22 h.drainStyling()
23 h.caret(at: 0)
24 h.drainStyling()
25 view.setAccessibilitySelectedTextRange(NSRange(location: 27, length: 4))
26 #expect(view.selectedRange() == NSRange(location: the + 4, length: 4))
27 #expect(view.accessibilitySelectedText() == "site")
28 // The caret's line now shows its markup, as on screen.
29 #expect(view.accessibilityValue() == "* Top\nbody\n* Links\nSee [[https://x.org][the site]] now.\n")
30 #expect(view.accessibilityLine(for: 7) == 1 && view.accessibilityRange(forLine: 1) == NSRange(location: 6, length: 5))
31 #expect(view.accessibilityString(for: NSRange(location: 19, length: 3)) == "See")
32
33 // Folded text is still read.
34 h.caret(at: 0)
35 h.editor.cycleAtCaret()
36 h.drainStyling()
37 #expect(view.accessibilityValue() == "* Top\nbody\n* Links\nSee the site now.\n")
38 #expect(view.accessibilityInsertionPointLineNumber() == 0)
39
40 // With markup shown, the whole text.
41 h.editor.showMarkup = true
42 h.drainStyling()
43 #expect(view.accessibilityValue() == text)
44 }
45
46 @Test func mapsBetweenShownAndBufferOffsets() {
47 let storage = NSMutableAttributedString(string: "a[[x][b]]c")
48 storage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: NSRange(location: 1, length: 5))
49 storage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: NSRange(location: 7, length: 2))
50 let shown = AccessibleText(storage)
51 #expect(shown.string == "abc" && shown.model == [0, 6, 9, 10])
52 #expect(shown.visible(NSRange(location: 6, length: 1)) == NSRange(location: 1, length: 1))
53 #expect(shown.buffer(NSRange(location: 0, length: 3)) == NSRange(location: 0, length: 10))
54 #expect(shown.buffer(NSRange(location: 3, length: 0)) == NSRange(location: 10, length: 0))
55 let pretty = NSMutableAttributedString(string: "x \\alpha y")
56 pretty.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: NSRange(location: 2, length: 5))
57 pretty.addAttribute(.orgEntityDisplay, value: "α", range: NSRange(location: 7, length: 1))
58 #expect(AccessibleText(pretty).string == "x α y")
59 }
60}