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 {
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}