krz/orgstar

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

docs/plans/2026-10-04-editing-spike.md

c1295824f55c7bba681fca8338bf08167e110105
orgstar/docs/plans/2026-10-04-editing-spike.md rendered · source · history · blame · raw

1183 lines · 52589 bytes

   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```