krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 7a7cb1803d

7a7cb1803d76e8add457243988b631e5cf19b5cb

parent: daac3706d5

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 07:23 UTC

Read the editor's text as shown to VoiceOver; label tabs, cards and rows

The editor's accessibility value leaves out hidden markup (link brackets and
targets, hidden emphasis markers and stars) and reads pretty entities and
shrunk cells as drawn; folded text is still read, as the spike decided.
Selection, character, line and frame queries map between the shown text and
the buffer. The shown text is cached until the storage changes (78 ms to
build for 2 MB).

Tabs are buttons with a selected state and a labelled close button; board
cards open and move between columns through accessibility actions; inspector
and column view rows are buttons.

Ref docs/review-2026-10-05.md tier 3 item 7

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