docs/plans/2026-10-04-editing-spike.md
1183 lines · 52589 bytes
7 symbols in this file
1# Editable TextKit 2 Spike Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Prove that a TextKit 2 text view can fold org subtrees and show org-indent while caret movement, selection, input methods, VoiceOver and copy/paste stay correct, with typing restyled inside a 16 ms frame. Find what doesn't work before phase 2 builds on it.
6
7**Architecture:** `OrgPresentation` is the platform-free layout policy: style runs, org-indent columns and hidden ranges, all computed from the tree. `OrgEditorAppKit` maps that policy onto an `NSTextView` with TextKit 2: folded paragraphs are skipped by `NSTextContentManagerDelegate.shouldEnumerate`, folded headings draw an ellipsis from a custom `NSTextLayoutFragment`, and every character edit flows from the text storage into `DocumentState.apply`. Only the affected region is restyled: the elements around the edit in the innermost section, computed on both the old and the new tree. `OrgstarSpike` is a throwaway app for trying it by hand.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, AppKit, TextKit 2.
10
11**Spec:** `docs/design.md`, "iOS considerations" (editor view row) and the phase 1 editing spike gate.
12
13## Global Constraints
14
15- `OrgPresentation` imports only `OrgCore`; it builds for iOS.
16- The text storage and `DocumentState` hold the same text after every edit, and the tree equals a full parse.
17- Incrementally restyled text equals a fresh editor's full styling after every edit.
18- Folded text stays in the storage: copy, accessibility and saving see all of it.
19
20## Findings
21
22Measured in release builds on an M-series Mac (not yet the M1 Air reference machine).
23
24| Check | Result |
25| --- | --- |
26| Folding by skipping enumeration | Works; layout drops folded paragraphs; ellipsis drawn after folded headings |
27| Caret | `moveDown` skips folds; `moveRight`/`moveLeft` would enter hidden text, so the editor moves the caret across the fold |
28| Copy across a fold | Includes the hidden text |
29| Input methods | Marked text and commit keep the document in sync |
30| Undo (`NSTextView`'s undo manager) | Keeps the document in sync |
31| Accessibility | `accessibilityValue` is the whole file, folds included; selection and line queries work. A manual VoiceOver pass is still needed (below) |
32| Typing, p95 | 5.8 ms on a 1.5 MB file, 3.4 ms on 0.86 MB (gate 16 ms) |
33| Blank line in a 1.2 MB top-level section | 256 ms; the reparse covers the whole top-level section (gate 16 ms) |
34| Open: parse + load and style | 313 + 124 ms for 1.5 MB; 126 + 101 ms for 0.86 MB (gate 100 ms for 1 MB) |
35| Restyle correctness | 10,500 random edits over seven seeds match a fresh full styling |
36
37Two bugs the differential test found and the plan fixes: a line split off by an inserted newline kept the old line's style, and a block or drawer whose delimiters changed left stale styling below the edit (fixed by restyling the union of the old and new trees' affected ranges).
38
39Work for the performance plan: reparse structural edits within the innermost section instead of the top-level section; a faster parser (about 3x for the open gate); style the visible region first on open and the rest after.
40
41Manual VoiceOver checklist (run `swift run OrgstarSpike <file>`, turn on VoiceOver):
42- [ ] Line by line through a folded heading skips to the next visible heading.
43- [ ] Reading a heading with tags and a TODO keyword is understandable.
44- [ ] Selection announcements are correct across a fold.
45
46## File structure
47
48| File | Responsibility |
49| --- | --- |
50| `Sources/OrgPresentation/Presentation.swift` | Style roles and runs, indent runs, hidden ranges, heading lookup |
51| `Sources/OrgEditorAppKit/OrgEditor.swift` | TextKit 2 editor: sync, folding, caret, restyle region, save |
52| `Sources/OrgEditorAppKit/Theme.swift` | Attributes per style role |
53| `Sources/OrgstarSpike/main.swift` | Runnable spike app |
54| `Sources/OrgDocument/ViewState.swift` | `pruned(to:)` descends only through containing sections |
55
56---
57
58### Task 1: Presentation policy
59
60**Files:**
61- Modify: `Package.swift` (add `OrgPresentation`)
62- Create: `Sources/OrgPresentation/Presentation.swift`
63- Test: `Tests/OrgPresentationTests/PresentationTests.swift`
64
65**Interfaces:**
66- Produces: `StyleRole`, `StyleRun`, `IndentRun`, `Presentation.styles(_:in:)`, `Presentation.indents(_:in:)`, `Presentation.hiddenRanges(_:folds:)`, `Presentation.heading(containing:in:)`, `Presentation.headings(_:)`.
67
68- [ ] **Step 1: Write the failing tests**
69
70```swift
71import OrgCore
72import Testing
73@testable import OrgPresentation
74
75func roles(_ text: String) -> [String] {
76 let tree = OrgParser.parse(text)
77 let utf16 = Array(text.utf16)
78 return Presentation.styles(tree, in: 0..<utf16.count).map { run in
79 "\(run.role):\(String(decoding: utf16[run.range], as: UTF16.self))"
80 }
81}
82
83struct StyleTests {
84 @Test func headingParts() {
85 #expect(roles("** DONE [#A] Ship *it* :work:\n") == [
86 "heading(level: 2):** DONE [#A] Ship *it* :work:\n",
87 "leadingStars:*", "done:DONE", "priority:[#A]", "tags::work:",
88 "bold:*it*", "markup:*", "markup:*",
89 ])
90 }
91
92 @Test func describedLinksDimTheirTarget() {
93 #expect(roles("[[https://a.b][site]]\n") == [
94 "link:[[https://a.b][site]]", "linkMarkup:[[", "linkMarkup:https://a.b", "linkMarkup:][", "linkMarkup:]]",
95 ])
96 #expect(roles("[[https://a.b]]\n") == ["link:[[https://a.b]]", "linkMarkup:[[", "linkMarkup:]]"])
97 }
98
99 @Test func blocksAndMetadata() {
100 #expect(roles("#+begin_src sh\necho\n#+end_src\n") == [
101 "block:#+begin_src sh\necho\n#+end_src\n", "blockDelimiter:#+begin_src sh", "blockDelimiter:#+end_src",
102 ])
103 #expect(roles("* a\nSCHEDULED: <2026-10-04 Sun>\n").contains("metadata:SCHEDULED: <2026-10-04 Sun>\n"))
104 }
105
106 @Test func onlyRunsOverlappingTheRange() {
107 let text = "* a\nfirst *b*\n* c\nsecond *d*\n"
108 let tree = OrgParser.parse(text)
109 let runs = Presentation.styles(tree, in: 17..<28)
110 #expect(!runs.contains { $0.range == 10..<13 })
111 #expect(runs.contains { $0.range == 25..<28 && $0.role == .bold })
112 }
113}
114
115struct IndentTests {
116 @Test func bodiesIndentPastTheirStars() {
117 let text = "pre\n* a\nbody\n** b\nmore\n"
118 let runs = Presentation.indents(OrgParser.parse(text), in: 0..<text.utf16.count)
119 #expect(runs == [
120 IndentRun(range: 4..<8, firstLine: 0, wrapped: 2),
121 IndentRun(range: 8..<13, firstLine: 2, wrapped: 2),
122 IndentRun(range: 13..<18, firstLine: 0, wrapped: 3),
123 IndentRun(range: 18..<23, firstLine: 3, wrapped: 3),
124 ])
125 }
126}
127
128struct FoldTests {
129 let text = "* a\nbody\n** b\nmore\n* c\ntext\n"
130
131 @Test func hiddenRanges() {
132 let tree = OrgParser.parse(text)
133 #expect(Presentation.hiddenRanges(tree, folds: [0]) == [4..<19])
134 #expect(Presentation.hiddenRanges(tree, folds: [9]) == [14..<19])
135 #expect(Presentation.hiddenRanges(tree, folds: [0, 9]) == [4..<19])
136 #expect(Presentation.hiddenRanges(tree, folds: [19]) == [23..<28])
137 #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty)
138 }
139
140 @Test func headingContaining() {
141 let tree = OrgParser.parse(text)
142 #expect(Presentation.heading(containing: 2, in: tree) == 0)
143 #expect(Presentation.heading(containing: 3, in: tree) == 0)
144 #expect(Presentation.heading(containing: 5, in: tree) == nil)
145 #expect(Presentation.heading(containing: 10, in: tree) == 9)
146 #expect(Presentation.headings(tree) == [0, 9, 19])
147 }
148}
149```
150
151- [ ] **Step 2: Implement**
152
153```swift
154import OrgCore
155
156/// What a range of text is, for styling. Platform adapters map roles to attributes.
157public enum StyleRole: Hashable, Sendable {
158 /// A whole heading line.
159 case heading(level: Int)
160 /// All stars of a heading but the last, hidden as in `org-hide-leading-stars`.
161 case leadingStars
162 case todo, done, priority, tags
163 case bold, italic, underline, strikeThrough, verbatim, code
164 /// Emphasis markers.
165 case markup
166 case link
167 /// Brackets and the target of a described link.
168 case linkMarkup
169 case timestamp, footnote, cookie, target, macro, latex, inlineSource
170 case comment, keyword
171 /// Planning lines, drawers, property drawers, clock lines.
172 case metadata
173 case block, blockDelimiter, table, rule
174}
175
176public struct StyleRun: Sendable, Equatable {
177 public let range: Range<Int>
178 public let role: StyleRole
179}
180
181/// org-indent: how many columns a run of whole lines starts in.
182public struct IndentRun: Sendable, Equatable {
183 public let range: Range<Int>
184 public let firstLine: Int
185 /// Continuation lines of a wrapped paragraph.
186 public let wrapped: Int
187}
188
189public enum Presentation {
190 /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
191 /// them in order lets inner roles win.
192 public static func styles(_ tree: OrgTree, in range: Range<Int>) -> [StyleRun] {
193 var runs: [StyleRun] = []
194 visit(tree.root, range, tree.settings, &runs)
195 return runs
196 }
197
198 private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ runs: inout [StyleRun]) {
199 if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
200 switch node.kind {
201 case .heading:
202 for token in node.tokens {
203 switch token.kind {
204 case .stars where token.range.count > 1:
205 runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
206 case .todoKeyword:
207 runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
208 case .priority:
209 runs.append(StyleRun(range: token.range, role: .priority))
210 case .tags:
211 runs.append(StyleRun(range: token.range, role: .tags))
212 default:
213 break
214 }
215 }
216 case .link:
217 let described = node.children.contains { $0.kind == .linkDescription }
218 for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
219 runs.append(StyleRun(range: token.range, role: .linkMarkup))
220 }
221 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
222 for token in node.tokens where token.kind == .marker {
223 runs.append(StyleRun(range: token.range, role: .markup))
224 }
225 case .block, .dynamicBlock:
226 let lines = node.tokens.filter { $0.kind == .text }
227 for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
228 runs.append(StyleRun(range: token.range, role: .blockDelimiter))
229 }
230 default:
231 break
232 }
233 for child in node.children where child.range.overlaps(range) {
234 visit(child, range, settings, &runs)
235 }
236 }
237
238 static func role(of node: SyntaxNode) -> StyleRole? {
239 switch node.kind {
240 case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
241 case .bold: .bold
242 case .italic: .italic
243 case .underline: .underline
244 case .strikeThrough: .strikeThrough
245 case .verbatim: .verbatim
246 case .code: .code
247 case .link: .link
248 case .timestamp: .timestamp
249 case .footnoteReference, .footnoteDefinition: .footnote
250 case .statisticsCookie: .cookie
251 case .target: .target
252 case .macro: .macro
253 case .latexFragment: .latex
254 case .inlineSourceBlock: .inlineSource
255 case .comment: .comment
256 case .keyword, .affiliatedKeyword: .keyword
257 case .planning, .propertyDrawer, .drawer, .clock: .metadata
258 case .block, .dynamicBlock, .fixedWidth: .block
259 case .table: .table
260 case .horizontalRule: .rule
261 default: nil
262 }
263 }
264
265 /// Heading lines start at column 0, with wrapped title lines under the title; a section's
266 /// own body starts one column past its stars.
267 public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
268 var runs: [IndentRun] = []
269 collectIndents(tree.root, range, &runs)
270 return runs
271 }
272
273 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
274 for section in node.children where section.kind == .section && section.range.overlaps(range) {
275 let children = section.children
276 guard let heading = children.first(where: { $0.kind == .heading }) else { continue }
277 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
278 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
279 let bodyEnd = children.first { $0.kind == .section }?.range.lowerBound ?? section.range.upperBound
280 if heading.range.upperBound < bodyEnd {
281 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
282 }
283 collectIndents(section, range, &runs)
284 }
285 }
286
287 /// Ranges to hide for folded headings, given by their start offsets: from the end of the
288 /// heading line to the end of its section. Sorted, without nested duplicates.
289 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
290 guard !folds.isEmpty else { return [] }
291 var ranges: [Range<Int>] = []
292 collectHidden(tree.root, folds, &ranges)
293 return ranges
294 }
295
296 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
297 for section in node.children where section.kind == .section {
298 guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
299 if folds.contains(heading.range.lowerBound) {
300 if heading.range.upperBound < section.range.upperBound {
301 ranges.append(heading.range.upperBound..<section.range.upperBound)
302 }
303 } else {
304 collectHidden(section, folds, &ranges)
305 }
306 }
307 }
308
309 /// Start offset of the heading whose line contains `offset`.
310 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
311 var node = tree.root
312 while let child = node.children.first(where: { $0.range.contains(offset) }) {
313 if child.kind == .heading { return child.range.lowerBound }
314 guard child.kind == .section || child.kind == .zerothSection else { return nil }
315 node = child
316 }
317 return nil
318 }
319
320 /// Start offsets of every heading, for folding everything.
321 public static func headings(_ tree: OrgTree) -> [Int] {
322 tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
323 }
324}
325```
326
327- [ ] **Step 3: Run, build for iOS, commit**
328
329Run: `swift test --filter OrgPresentationTests`, then `xcodebuild -scheme OrgPresentation -destination 'generic/platform=iOS' build`.
330
331```bash
332git add Package.swift Sources/OrgPresentation Tests/OrgPresentationTests
333git commit -m "Add platform-free presentation policy"
334```
335
336---
337
338### Task 2: AppKit editor
339
340**Files:**
341- Modify: `Package.swift` (add `OrgEditorAppKit`), `Sources/OrgDocument/ViewState.swift`
342- Create: `Sources/OrgEditorAppKit/OrgEditor.swift`, `Sources/OrgEditorAppKit/Theme.swift`
343- Test: `Tests/OrgEditorAppKitTests/EditorTests.swift`
344
345**Interfaces:**
346- Consumes: `DocumentState`, `ViewState`, `Saver`, `lineEdits` (OrgDocument); Task 1.
347- Produces: `OrgEditor(document:frame:)` with `textView`, `document`, `view`, `onEditTiming`, `makeScrollView()`, `setFolds(_:)`, `toggleFoldAtCaret()`, `toggleAllFolds()`, `save(using:to:)`.
348
349- [ ] **Step 1: Make pruning cheap**
350
351```diff
352@@ -22,11 +22,22 @@ public struct ViewState: Sendable, Equatable {
353
354 /// Drops folds that no longer sit at the start of a heading.
355 public func pruned(to tree: OrgTree) -> ViewState {
356- let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound))
357- return ViewState(selection: selection, folds: folds.intersection(headings))
358+ guard !folds.isEmpty else { return self }
359+ return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
360 }
361 }
362
363+/// Descends only through the sections containing `offset`.
364+func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
365+ var node = root
366+ while let child = node.children.first(where: { $0.range.contains(offset) }) {
367+ if child.kind == .heading { return child.range.lowerBound == offset }
368+ guard child.kind == .section || child.kind == .zerothSection else { return false }
369+ node = child
370+ }
371+ return false
372+}
373+
374 /// An offset before an edit stays put; one at the start of a replaced range stays at its start;
375 /// one inside it, at its end, or at an insertion point moves past the replacement.
376 func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
377```
378
379- [ ] **Step 2: Write the failing tests**
380
381The tests drive a real `NSTextView` in an offscreen window. `RestyleFuzzTests` compares the incrementally styled storage with a fresh editor after every random edit.
382
383```swift
384import AppKit
385import OrgCore
386import OrgDocument
387import Testing
388@testable import OrgEditorAppKit
389
390/// An editor in an offscreen window, laid out.
391@MainActor
392final class Harness {
393 let editor: OrgEditor
394 let window: NSWindow
395
396 init(_ text: String) {
397 _ = NSApplication.shared
398 editor = OrgEditor(document: DocumentState(bytes: Array(text.utf8)))
399 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
400 window.contentView = editor.makeScrollView()
401 window.makeFirstResponder(editor.textView)
402 layout()
403 }
404
405 var textView: NSTextView { editor.textView }
406 var string: String { textView.string }
407
408 func layout() {
409 let manager = editor.textLayoutManager
410 manager.ensureLayout(for: manager.documentRange)
411 }
412
413 /// Text of each laid-out paragraph.
414 var visibleLines: [String] {
415 layout()
416 let manager = editor.textLayoutManager
417 var lines: [String] = []
418 manager.enumerateTextLayoutFragments(from: manager.documentRange.location, options: [.ensuresLayout]) { fragment in
419 let content = manager.textContentManager!
420 let start = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.location)
421 let end = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.endLocation)
422 lines.append((self.string as NSString).substring(with: NSRange(location: start, length: end - start)))
423 return true
424 }
425 return lines.filter { !$0.isEmpty }
426 }
427
428 func caret(at offset: Int) {
429 textView.setSelectedRange(NSRange(location: offset, length: 0))
430 }
431
432 var caret: Int { textView.selectedRange().location }
433
434 func offset(of line: String) -> Int {
435 (string as NSString).range(of: line).location
436 }
437
438 func checkInSync() {
439 #expect(editor.document.text == string)
440 #expect(editor.document.tree.green == OrgParser.parse(string).green)
441 }
442}
443
444let outline = "* a\nbody\n** b\nmore\n* c\ntext\n"
445
446@MainActor
447struct FoldingTests {
448 @Test func foldingHidesTheSubtree() {
449 let h = Harness(outline)
450 #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
451 h.caret(at: 1)
452 #expect(h.editor.toggleFoldAtCaret())
453 #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
454 h.editor.toggleFoldAtCaret()
455 #expect(h.visibleLines.count == 6)
456 }
457
458 @Test func tabFoldsOnlyOnHeadings() {
459 let h = Harness(outline)
460 h.caret(at: 5)
461 #expect(!h.editor.toggleFoldAtCaret())
462 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
463 #expect(h.visibleLines == ["* a\n", "* c\n"])
464 }
465
466 @Test func caretMovesOverFoldedText() {
467 let h = Harness(outline)
468 h.caret(at: 1)
469 h.editor.toggleFoldAtCaret()
470 h.caret(at: 0)
471 h.textView.moveDown(nil)
472 #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
473 h.caret(at: 3)
474 h.textView.moveRight(nil)
475 #expect(h.caret == h.offset(of: "* c"))
476 h.textView.moveLeft(nil)
477 #expect(h.caret == 3)
478 }
479
480 @Test func copyingAcrossAFoldIncludesHiddenText() {
481 let h = Harness(outline)
482 h.caret(at: 1)
483 h.editor.toggleFoldAtCaret()
484 h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
485 let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
486 defer { pasteboard.releaseGlobally() }
487 #expect(h.textView.writeSelection(to: pasteboard, types: h.textView.writablePasteboardTypes))
488 #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n")
489 }
490
491 @Test func foldsFollowEdits() {
492 let h = Harness(outline)
493 h.caret(at: h.offset(of: "* c") + 1)
494 h.editor.toggleFoldAtCaret()
495 h.caret(at: 0)
496 h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
497 #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
498 h.checkInSync()
499 }
500}
501
502@MainActor
503struct EditingTests {
504 @Test func typingKeepsTheDocumentInSync() {
505 let h = Harness(outline)
506 h.caret(at: h.offset(of: "body") + 4)
507 h.textView.insertText(" *bold*", replacementRange: h.textView.selectedRange())
508 h.textView.insertNewline(nil)
509 h.textView.insertText("* new heading", replacementRange: h.textView.selectedRange())
510 h.textView.deleteBackward(nil)
511 h.checkInSync()
512 #expect(h.editor.document.isDirty)
513 }
514
515 @Test func inputMethodComposition() {
516 let h = Harness("* a\n")
517 h.caret(at: 3)
518 h.textView.setMarkedText("か", selectedRange: NSRange(location: 1, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
519 #expect(h.textView.hasMarkedText())
520 h.textView.setMarkedText("かん", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
521 h.textView.insertText("漢", replacementRange: NSRange(location: NSNotFound, length: 0))
522 #expect(!h.textView.hasMarkedText())
523 #expect(h.string == "* a漢\n")
524 h.checkInSync()
525 }
526
527 @Test func undoKeepsTheDocumentInSync() {
528 let h = Harness(outline)
529 h.caret(at: 3)
530 h.textView.insertText("xyz", replacementRange: h.textView.selectedRange())
531 h.textView.breakUndoCoalescing()
532 h.textView.undoManager?.undo()
533 #expect(h.string == outline)
534 h.checkInSync()
535 }
536
537 @Test func styling() throws {
538 let h = Harness("** b\nbody\n")
539 let storage = try #require(h.textView.textStorage)
540 #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .clear)
541 let style = try #require(storage.attribute(.paragraphStyle, at: 6, effectiveRange: nil) as? NSParagraphStyle)
542 #expect(style.firstLineHeadIndent == 3 * h.editor.theme.column)
543 h.caret(at: 0)
544 h.textView.insertText("*", replacementRange: NSRange(location: 0, length: 0))
545 let deeper = try #require(storage.attribute(.paragraphStyle, at: 7, effectiveRange: nil) as? NSParagraphStyle)
546 #expect(deeper.firstLineHeadIndent == 4 * h.editor.theme.column)
547 }
548
549 @Test func accessibilityReadsTheWholeText() {
550 let h = Harness(outline)
551 h.caret(at: 1)
552 h.editor.toggleFoldAtCaret()
553 #expect(h.textView.accessibilityValue() as? String == outline)
554 h.textView.setSelectedRange(NSRange(location: 0, length: 3))
555 #expect(h.textView.accessibilitySelectedText() == "* a")
556 #expect(h.textView.accessibilityLine(for: h.offset(of: "* c")) >= 1)
557 }
558
559 @Test func saveWritesAndClearsDirty() throws {
560 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
561 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
562 defer { try? FileManager.default.removeItem(at: folder) }
563 let file = folder.appendingPathComponent("a.org")
564 try Data(outline.utf8).write(to: file)
565 let h = Harness(outline)
566 h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
567 let saver = Saver(fileSystem: CoordinatedFileSystem(), recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
568 #expect(try h.editor.save(using: saver, to: file) == .saved)
569 #expect(!h.editor.document.isDirty)
570 #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline)
571 }
572}
573
574@MainActor
575struct RestyleTests {
576 func indent(_ h: Harness, at line: String) -> CGFloat? {
577 let style = h.textView.textStorage?.attribute(.paragraphStyle, at: h.offset(of: line), effectiveRange: nil) as? NSParagraphStyle
578 return style.map { $0.firstLineHeadIndent / h.editor.theme.column }
579 }
580
581 @Test func newHeadingReindentsTheLinesItTakesOver() {
582 let h = Harness("* a\none\ntwo\nthree\n")
583 h.textView.insertText("** b\n", replacementRange: NSRange(location: h.offset(of: "two"), length: 0))
584 #expect(indent(h, at: "one") == 2)
585 #expect(indent(h, at: "three") == 3)
586 h.checkInSync()
587 }
588
589 @Test func joiningAwayAHeadingReindentsItsBody() {
590 let h = Harness("* a\none\n** b\ntwo\nthree\n")
591 h.textView.insertText("", replacementRange: NSRange(location: h.offset(of: "** b") - 1, length: 1))
592 #expect(indent(h, at: "three") == 2)
593 h.checkInSync()
594 }
595
596 @Test func typingASpaceAfterAStarMakesAHeading() {
597 let h = Harness("* a\n*x\nbody\nmore\n")
598 h.textView.insertText(" ", replacementRange: NSRange(location: h.offset(of: "*x") + 1, length: 0))
599 #expect(indent(h, at: "more") == 2)
600 h.checkInSync()
601 }
602}
603
604@MainActor
605struct RestyleFuzzTests {
606 /// After each random edit, the restyled text must equal a fresh editor's full styling. Set
607 /// `ORGSTAR_RESTYLE_EDITS` and `ORGSTAR_FUZZ_SEED` for longer runs.
608 @Test func incrementalRestyleEqualsFullRestyle() throws {
609 let environment = ProcessInfo.processInfo.environment
610 var state = UInt64(environment["ORGSTAR_FUZZ_SEED"] ?? "") ?? 20261006
611 let count = Int(environment["ORGSTAR_RESTYLE_EDITS"] ?? "") ?? 300
612 func next(_ bound: Int) -> Int {
613 state = state &* 6364136223846793005 &+ 1442695040888963407
614 return Int((state >> 33) % UInt64(bound))
615 }
616 let pieces = ["* ", "** ", "*", " ", "\n", "x", "*b*", "TODO ", "- ", "| a |", "#+begin_src\n", "#+end_src\n", ":PROPERTIES:\n", ":END:\n", "[[l][d]]", "<2026-10-04 Sun>"]
617 let h = Harness("* a\nbody *b*\n** b\n- item\nmore\n* c\ntext\n")
618 for n in 0..<count {
619 let length = (h.string as NSString).length
620 let start = next(length + 1)
621 let end = min(length, start + next(4))
622 let replacement = next(3) == 0 ? "" : pieces[next(pieces.count)]
623 h.textView.insertText(replacement, replacementRange: NSRange(location: start, length: end - start))
624 let fresh = Harness(h.string)
625 let same = h.textView.textStorage!.isEqual(to: fresh.textView.textStorage!)
626 #expect(same, "edit \(n): \(start)..<\(end) \(replacement.debugDescription) -> \(h.string.debugDescription)")
627 if !same { break }
628 }
629 h.checkInSync()
630 }
631}
632```
633
634- [ ] **Step 3: Implement `Theme.swift`**
635
636```swift
637#if os(macOS)
638import AppKit
639import OrgPresentation
640
641/// Attributes for each style role. A fixed theme for now; the app's theme settings replace it.
642@MainActor
643struct Theme {
644 let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
645
646 var base: [NSAttributedString.Key: Any] {
647 [.font: font, .foregroundColor: NSColor.textColor]
648 }
649
650 /// Width of one column of the monospaced font.
651 var column: CGFloat {
652 ("0" as NSString).size(withAttributes: [.font: font]).width
653 }
654
655 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
656 let style = NSMutableParagraphStyle()
657 style.firstLineHeadIndent = CGFloat(firstLine) * column
658 style.headIndent = CGFloat(wrapped) * column
659 return style
660 }
661
662 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
663 switch role {
664 case .heading(let level):
665 let size = font.pointSize + CGFloat(max(0, 4 - level))
666 storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold), range: range)
667 case .leadingStars:
668 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
669 case .todo:
670 storage.addAttributes([.foregroundColor: NSColor.systemRed, .font: bold(storage, range)], range: range)
671 case .done:
672 storage.addAttributes([.foregroundColor: NSColor.systemGreen, .font: bold(storage, range)], range: range)
673 case .priority:
674 storage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: range)
675 case .tags, .comment, .keyword, .metadata:
676 storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range)
677 case .bold:
678 storage.addAttribute(.font, value: bold(storage, range), range: range)
679 case .italic:
680 storage.addAttribute(.font, value: NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .italicFontMask), range: range)
681 case .underline:
682 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
683 case .strikeThrough:
684 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
685 case .verbatim, .code, .inlineSource:
686 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
687 case .markup, .linkMarkup, .blockDelimiter:
688 storage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: range)
689 case .link:
690 storage.addAttributes([.foregroundColor: NSColor.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: range)
691 case .timestamp:
692 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range)
693 case .footnote, .cookie, .target, .macro, .latex:
694 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range)
695 case .block:
696 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
697 case .table, .rule:
698 break
699 }
700 }
701
702 private func currentFont(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
703 storage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont ?? font
704 }
705
706 private func bold(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
707 NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .boldFontMask)
708 }
709}
710#endif
711```
712
713- [ ] **Step 4: Implement `OrgEditor.swift`**
714
715```swift
716#if os(macOS)
717import AppKit
718import OrgCore
719import OrgDocument
720import OrgPresentation
721
722/// Hidden ranges, read by TextKit's enumeration callbacks, which may run off the main thread.
723final class HiddenRanges: @unchecked Sendable {
724 private let lock = NSLock()
725 private var ranges: [Range<Int>] = []
726
727 func set(_ new: [Range<Int>]) {
728 lock.withLock { ranges = new }
729 }
730
731 var all: [Range<Int>] {
732 lock.withLock { ranges }
733 }
734
735 func covers(_ start: Int, _ end: Int) -> Bool {
736 lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } }
737 }
738
739 func containing(_ offset: Int) -> Range<Int>? {
740 lock.withLock { ranges.first { $0.lowerBound <= offset && offset < $0.upperBound } }
741 }
742}
743
744/// Folded heading starts, read by the layout-fragment callback.
745final class FoldedHeadings: @unchecked Sendable {
746 private let lock = NSLock()
747 private var starts: Set<Int> = []
748
749 func set(_ new: Set<Int>) {
750 lock.withLock { starts = new }
751 }
752
753 func contains(_ offset: Int) -> Bool {
754 lock.withLock { starts.contains(offset) }
755 }
756}
757
758/// A TextKit 2 text view over a `DocumentState`. The text storage mirrors the document; every
759/// character edit goes through `DocumentState.apply`, and only the affected region is restyled.
760@MainActor
761public final class OrgEditor: NSObject {
762 public let textView: NSTextView
763 public private(set) var document: DocumentState
764 public private(set) var view = ViewState()
765 /// Called with the time from a character edit to restyled text.
766 public var onEditTiming: ((Duration) -> Void)?
767
768 let hidden = HiddenRanges()
769 let folded = FoldedHeadings()
770 let theme = Theme()
771 private var isLoading = false
772 private var previousSelection = 0
773
774 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
775 self.document = document
776 textView = NSTextView(usingTextLayoutManager: true)
777 super.init()
778 textView.frame = frame
779 textView.isRichText = false
780 textView.allowsUndo = true
781 textView.isAutomaticQuoteSubstitutionEnabled = false
782 textView.isAutomaticDashSubstitutionEnabled = false
783 textView.isAutomaticTextReplacementEnabled = false
784 textView.isAutomaticSpellingCorrectionEnabled = false
785 textView.font = theme.font
786 textView.typingAttributes = theme.base
787 textView.isVerticallyResizable = true
788 textView.autoresizingMask = [.width]
789 textView.textContainer?.widthTracksTextView = true
790 textView.delegate = self
791 textView.textStorage?.delegate = self
792 textLayoutManager.delegate = self
793 textContentStorage.delegate = self
794
795 isLoading = true
796 textView.string = document.text
797 isLoading = false
798 restyleOutsideEditing(0..<utf16Length)
799 }
800
801 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
802 var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
803 var utf16Length: Int { (textView.string as NSString).length }
804
805 /// A scroll view holding the text view, ready for a window.
806 public func makeScrollView() -> NSScrollView {
807 let scroll = NSScrollView(frame: textView.frame)
808 scroll.hasVerticalScroller = true
809 scroll.documentView = textView
810 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
811 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
812 return scroll
813 }
814
815 // MARK: - Saving
816
817 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and
818 /// folds are mapped through the change.
819 @discardableResult
820 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
821 let before = document.text
822 let outcome = try saver.save(&document, to: url)
823 if document.text != before {
824 let edits = lineEdits(from: before, to: document.text)
825 isLoading = true
826 textView.string = document.text
827 isLoading = false
828 restyleOutsideEditing(0..<utf16Length)
829 setFolds(view.mapped(through: edits).folds)
830 }
831 return outcome
832 }
833
834 // MARK: - Folding
835
836 public func setFolds(_ folds: Set<Int>) {
837 view.folds = folds
838 view = view.pruned(to: document.tree)
839 folded.set(view.folds)
840 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
841 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
842 textView.needsDisplay = true
843 let caret = textView.selectedRange().location
844 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
845 }
846
847 /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
848 @discardableResult
849 public func toggleFoldAtCaret() -> Bool {
850 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
851 var folds = view.folds
852 if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
853 setFolds(folds)
854 return true
855 }
856
857 /// Folds every heading, or unfolds everything if anything is folded.
858 public func toggleAllFolds() {
859 setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
860 }
861
862 private func setCaret(_ offset: Int) {
863 let clamped = max(0, min(offset, utf16Length))
864 previousSelection = clamped
865 textView.setSelectedRange(NSRange(location: clamped, length: 0))
866 }
867
868 // MARK: - Styling
869
870 private func restyleOutsideEditing(_ range: Range<Int>) {
871 guard let storage = textView.textStorage else { return }
872 storage.beginEditing()
873 restyle(range, in: storage)
874 storage.endEditing()
875 }
876
877 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
878 /// attribute changes are allowed and characters must not change.
879 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
880 guard !range.isEmpty else { return }
881 let whole = NSRange(range)
882 storage.setAttributes(theme.base, range: whole)
883 for run in Presentation.indents(document.tree, in: range) {
884 guard let clipped = clip(run.range, to: range) else { continue }
885 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
886 }
887 for run in Presentation.styles(document.tree, in: range) {
888 guard let clipped = clip(run.range, to: range) else { continue }
889 theme.apply(run.role, to: storage, range: clipped)
890 }
891 }
892
893 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
894 let clipped = run.clamped(to: range)
895 return clipped.isEmpty ? nil : NSRange(clipped)
896 }
897
898 /// The region an edit affects in one version of the tree: inside the innermost section
899 /// holding the edited lines (plus the line after), every element that overlaps them or the
900 /// line on either side. A changed heading line, or an added or removed heading, widens it
901 /// to that section's whole own body. Run on the old and the new tree, the union covers
902 /// elements that grew and elements that shrank.
903 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
904 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
905 let lineRange = lines.location..<(lines.location + lines.length)
906 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
907 var container = tree.root
908 while let child = container.children.first(where: {
909 ($0.kind == .section || $0.kind == .zerothSection)
910 && $0.range.lowerBound <= lineRange.lowerBound && lineRange.upperBound <= $0.range.upperBound
911 }) {
912 container = child
913 }
914 var lower = lineRange.lowerBound
915 var upper = lineRange.upperBound
916 let children = container.children
917 for child in children where child.range.overlaps(around) {
918 lower = min(lower, child.range.lowerBound)
919 upper = max(upper, child.range.upperBound)
920 }
921 let heading = children.first { $0.kind == .heading }
922 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
923 lower = min(lower, container.range.lowerBound)
924 upper = max(upper, children.first { $0.kind == .section }?.range.lowerBound ?? container.range.upperBound)
925 }
926 return lower..<min(upper, text.length)
927 }
928
929 // MARK: - Editing
930
931 fileprivate func storageEdited(range: NSRange, delta: Int) {
932 guard !isLoading, let storage = textView.textStorage else { return }
933 let clock = ContinuousClock()
934 let start = clock.now
935 let replacement = (storage.string as NSString).substring(with: range)
936 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
937 let oldText = document.text as NSString
938 let newText = storage.string as NSString
939 let newEnd = edit.range.lowerBound + replacement.utf16.count
940 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
941 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
942 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
943 do {
944 try document.apply([edit], baseRevision: document.revision)
945 } catch {
946 assertionFailure("text view and document disagree: \(error)")
947 return
948 }
949 let oldHidden = hidden.all
950 view = view.mapped(through: [edit]).pruned(to: document.tree)
951 folded.set(view.folds)
952 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
953 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
954 let delta = edit.replacement.utf16.count - edit.range.count
955 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
956 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
957 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
958 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
959 }
960 onEditTiming?(clock.now - start)
961 }
962
963 /// Whether a heading line touches `start..<end` or starts right after it, where joining
964 /// lines could take it away.
965 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
966 let length = min(end + 1, text.length) - start
967 guard length >= 0 else { return false }
968 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
969 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
970 let stars = line.prefix { $0 == "*" }
971 let after = line.dropFirst(stars.count)
972 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
973 }
974 }
975
976 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
977 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
978 return mapped
979 }
980
981 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
982 /// back jumps to the end of the heading line.
983 fileprivate func selectionChanged() {
984 let selection = textView.selectedRange()
985 defer { previousSelection = selection.location }
986 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
987 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
988 }
989}
990
991extension OrgEditor: NSTextViewDelegate {
992 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
993 switch selector {
994 case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
995 case #selector(NSResponder.insertBacktab(_:)):
996 toggleAllFolds()
997 return true
998 default: return false
999 }
1000 }
1001
1002 public func textViewDidChangeSelection(_ notification: Notification) {
1003 selectionChanged()
1004 }
1005}
1006
1007extension OrgEditor: NSTextStorageDelegate {
1008 public nonisolated func textStorage(
1009 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
1010 ) {
1011 guard editedMask.contains(.editedCharacters) else { return }
1012 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
1013 }
1014}
1015
1016extension OrgEditor: NSTextContentStorageDelegate {
1017 /// Folded text isn't enumerated, so it isn't laid out or drawn.
1018 public nonisolated func textContentManager(
1019 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
1020 ) -> Bool {
1021 guard let range = textElement.elementRange else { return true }
1022 let documentStart = textContentManager.documentRange.location
1023 let start = textContentManager.offset(from: documentStart, to: range.location)
1024 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
1025 return !hidden.covers(start, end)
1026 }
1027}
1028
1029extension OrgEditor: NSTextLayoutManagerDelegate {
1030 public nonisolated func textLayoutManager(
1031 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
1032 ) -> NSTextLayoutFragment {
1033 let range = textElement.elementRange
1034 if let range, let manager = textLayoutManager.textContentManager {
1035 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
1036 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
1037 }
1038 return NSTextLayoutFragment(textElement: textElement, range: range)
1039 }
1040}
1041
1042/// A heading line drawn with an ellipsis after it, like org's folded headings.
1043final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
1044 override func draw(at point: CGPoint, in context: CGContext) {
1045 super.draw(at: point, in: context)
1046 guard let line = textLineFragments.first else { return }
1047 let bounds = line.typographicBounds
1048 let ellipsis = NSAttributedString(string: " …", attributes: [
1049 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
1050 .foregroundColor: NSColor.secondaryLabelColor,
1051 ])
1052 NSGraphicsContext.saveGraphicsState()
1053 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
1054 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
1055 NSGraphicsContext.restoreGraphicsState()
1056 }
1057
1058 override var renderingSurfaceBounds: CGRect {
1059 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
1060 }
1061}
1062#endif
1063```
1064
1065- [ ] **Step 5: Run, including longer fuzz runs, then commit**
1066
1067Run: `swift test`, then `ORGSTAR_RESTYLE_EDITS=1500 ORGSTAR_FUZZ_SEED=<n> swift test --filter RestyleFuzzTests` for several seeds.
1068
1069```bash
1070git add Package.swift Sources/OrgDocument/ViewState.swift Sources/OrgEditorAppKit Tests/OrgEditorAppKitTests
1071git commit -m "Add TextKit 2 editor with folding and incremental restyle"
1072```
1073
1074---
1075
1076### Task 3: Spike app
1077
1078**Files:**
1079- Modify: `Package.swift` (add the `OrgstarSpike` executable)
1080- Create: `Sources/OrgstarSpike/main.swift`
1081
1082- [ ] **Step 1: Implement**
1083
1084```swift
1085#if os(macOS)
1086import AppKit
1087import OrgDocument
1088import OrgEditorAppKit
1089
1090/// A throwaway app for trying the editor by hand: `swift run OrgstarSpike path/to/file.org`.
1091/// TAB on a heading folds it, Shift-TAB folds everything, ⌘S saves. Edit timings are printed
1092/// every 50 edits.
1093@MainActor
1094final class SpikeDelegate: NSObject, NSApplicationDelegate {
1095 let url: URL
1096 var editor: OrgEditor?
1097 var window: NSWindow?
1098 var timings: [Duration] = []
1099
1100 init(url: URL) {
1101 self.url = url
1102 }
1103
1104 func applicationDidFinishLaunching(_ notification: Notification) {
1105 let bytes = (try? [UInt8](Data(contentsOf: url))) ?? []
1106 let editor = OrgEditor(document: DocumentState(bytes: bytes), frame: NSRect(x: 0, y: 0, width: 900, height: 1000))
1107 editor.onEditTiming = { [weak self] in self?.record($0) }
1108 let window = NSWindow(
1109 contentRect: NSRect(x: 100, y: 100, width: 900, height: 1000),
1110 styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false
1111 )
1112 window.title = url.lastPathComponent
1113 window.contentView = editor.makeScrollView()
1114 window.makeKeyAndOrderFront(nil)
1115 window.makeFirstResponder(editor.textView)
1116 self.editor = editor
1117 self.window = window
1118 buildMenu()
1119 NSApp.activate()
1120 }
1121
1122 func buildMenu() {
1123 let main = NSMenu()
1124 let appItem = NSMenuItem()
1125 appItem.submenu = NSMenu()
1126 appItem.submenu?.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
1127 let fileItem = NSMenuItem()
1128 fileItem.submenu = NSMenu(title: "File")
1129 fileItem.submenu?.addItem(withTitle: "Save", action: #selector(save), keyEquivalent: "s").target = self
1130 let editItem = NSMenuItem()
1131 editItem.submenu = NSMenu(title: "Edit")
1132 editItem.submenu?.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
1133 editItem.submenu?.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
1134 editItem.submenu?.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
1135 editItem.submenu?.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
1136 editItem.submenu?.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
1137 editItem.submenu?.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
1138 [appItem, fileItem, editItem].forEach(main.addItem)
1139 NSApp.mainMenu = main
1140 }
1141
1142 @objc func save() {
1143 guard let editor else { return }
1144 let recovery = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
1145 .appendingPathComponent("Orgstar/Recovery")
1146 let saver = Saver(fileSystem: CoordinatedFileSystem(), recovery: FileRecoveryStore(directory: recovery))
1147 do {
1148 print("save:", try editor.save(using: saver, to: url))
1149 } catch {
1150 print("save failed:", error)
1151 }
1152 }
1153
1154 func record(_ duration: Duration) {
1155 timings.append(duration)
1156 guard timings.count % 50 == 0 else { return }
1157 let sorted = timings.sorted()
1158 print("edits: \(timings.count) p50 \(sorted[sorted.count / 2]) p95 \(sorted[sorted.count * 95 / 100])")
1159 }
1160
1161 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
1162}
1163
1164guard CommandLine.arguments.count > 1 else {
1165 print("usage: OrgstarSpike path/to/file.org")
1166 exit(2)
1167}
1168let app = NSApplication.shared
1169app.setActivationPolicy(.regular)
1170let delegate = SpikeDelegate(url: URL(fileURLWithPath: CommandLine.arguments[1]))
1171app.delegate = delegate
1172app.run()
1173#endif
1174```
1175
1176- [ ] **Step 2: Run by hand, measure, commit**
1177
1178Run: `swift run -c release OrgstarSpike <large file>`; type, fold with TAB and Shift-TAB, save with ⌘S, watch the printed p50/p95; then the VoiceOver checklist above.
1179
1180```bash
1181git add Package.swift Sources/OrgstarSpike
1182git commit -m "Add spike app"
1183```