VoiceOver reads the editor as shown; labelled tabs, cards and rows !107
8 files changed, +285 −0
Layout: unified · split
Sources/OrgEditorAppKit/Accessibility.swift added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | #if os(macOS) | |
| 2 | import 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. | |
| 8 | struct 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 | ||
| 114 | extension 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 { | ||
| 14 | 14 | final class OrgTextView: NSTextView { |
| 15 | 15 | /// Returns true when the key was handled. |
| 16 | 16 | var keyHandler: ((NSEvent) -> Bool)? |
| 17 | /// The text as shown, for accessibility. | |
| 18 | var accessibleText: (() -> AccessibleText)? | |
| 17 | 19 | var onMouseDown: (() -> Void)? |
| 18 | 20 | /// Each editor keeps its own undo history, not the window's. |
| 19 | 21 | private let ownUndoManager = UndoManager() |
Sources/OrgEditorAppKit/OrgEditor.swift +12
| @@ -97,6 +97,10 @@ public final class OrgEditor: NSObject { | ||
| 97 | 97 | |
| 98 | 98 | public let mode: Mode |
| 99 | 99 | 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 | |
| 100 | 104 | /// The open completion list, and the pause before one opens by itself. |
| 101 | 105 | var completionPopup: CompletionPopup? |
| 102 | 106 | var completionTimer: DispatchWorkItem? |
| @@ -125,6 +129,13 @@ public final class OrgEditor: NSObject { | ||
| 125 | 129 | return true |
| 126 | 130 | } |
| 127 | 131 | 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 | } | |
| 128 | 139 | orgTextView.onMouseDown = { [weak self] in |
| 129 | 140 | self?.markActive = false |
| 130 | 141 | self?.modalMouseDown() |
| @@ -1351,6 +1362,7 @@ extension OrgEditor: NSTextStorageDelegate { | ||
| 1351 | 1362 | public nonisolated func textStorage( |
| 1352 | 1363 | _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int |
| 1353 | 1364 | ) { |
| 1365 | MainActor.assumeIsolated { storageGeneration += 1 } | |
| 1354 | 1366 | guard editedMask.contains(.editedCharacters) else { return } |
| 1355 | 1367 | MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) } |
| 1356 | 1368 | } |
Sources/Orgstar/BoardView.swift +9
| @@ -152,6 +152,15 @@ struct BoardView: View { | ||
| 152 | 152 | card(entry) |
| 153 | 153 | .draggable(entry.id) |
| 154 | 154 | .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 | } | |
| 155 | 164 | } |
| 156 | 165 | } |
| 157 | 166 | } |
Sources/Orgstar/ColumnViewSheet.swift +5
| @@ -35,6 +35,11 @@ struct ColumnViewSheet: View { | ||
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | .contentShape(Rectangle()) |
| 38 | .accessibilityAddTraits(.isButton) | |
| 39 | .accessibilityAction { | |
| 40 | jump(row.start) | |
| 41 | dismiss() | |
| 42 | } | |
| 38 | 43 | .onTapGesture { |
| 39 | 44 | jump(row.start) |
| 40 | 45 | dismiss() |
Sources/Orgstar/ContentView.swift +3
| @@ -891,6 +891,8 @@ struct BufferTabs: View { | ||
| 891 | 891 | Text((path as NSString).lastPathComponent) |
| 892 | 892 | .lineLimit(1) |
| 893 | 893 | .foregroundStyle(current ? .primary : .secondary) |
| 894 | .accessibilityAddTraits(current ? [.isButton, .isSelected] : .isButton) | |
| 895 | .accessibilityAction { session.open(path) } | |
| 894 | 896 | Button { |
| 895 | 897 | if path == session.path { |
| 896 | 898 | if UnsavedChanges.resolve(session) { session.kill(path) } |
| @@ -904,6 +906,7 @@ struct BufferTabs: View { | ||
| 904 | 906 | } |
| 905 | 907 | .buttonStyle(.borderless) |
| 906 | 908 | .help("Close") |
| 909 | .accessibilityLabel("Close \((path as NSString).lastPathComponent)" + (session.dirtyPaths.contains(path) ? ", unsaved" : "")) | |
| 907 | 910 | } |
| 908 | 911 | .font(.callout) |
| 909 | 912 | .padding(.horizontal, 12) |
Sources/Orgstar/InspectorPane.swift +5
| @@ -73,6 +73,8 @@ struct InspectorPane: View { | ||
| 73 | 73 | } |
| 74 | 74 | .contentShape(Rectangle()) |
| 75 | 75 | .onTapGesture { session.reveal(row.start) } |
| 76 | .accessibilityAddTraits(.isButton) | |
| 77 | .accessibilityAction { session.reveal(row.start) } | |
| 76 | 78 | } |
| 77 | 79 | } |
| 78 | 80 | .font(.system(.callout, design: .monospaced)) |
| @@ -105,6 +107,9 @@ struct InspectorPane: View { | ||
| 105 | 107 | .fontWeight(entry.start == heading ? .semibold : .regular) |
| 106 | 108 | .contentShape(Rectangle()) |
| 107 | 109 | .onTapGesture { session.reveal(entry.start) } |
| 110 | .accessibilityElement(children: .combine) | |
| 111 | .accessibilityAddTraits(.isButton) | |
| 112 | .accessibilityAction { session.reveal(entry.start) } | |
| 108 | 113 | } |
| 109 | 114 | HStack { |
| 110 | 115 | Text("Total").bold() |
Tests/OrgEditorAppKitTests/AccessibilityTests.swift added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | import AppKit | |
| 2 | import Testing | |
| 3 | @testable import OrgEditorAppKit | |
| 4 | ||
| 5 | /// What VoiceOver reads: the text as shown, with ranges and lines in its terms. | |
| 6 | @MainActor | |
| 7 | struct 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 | } | |