Editable TextKit 2 spike !7

merged merged by cmc on 2026-10-05 01:03 UTC · krz/orgstar:phase1-editing-spike into main

9 files changed, +2213 −4

Layout: unified · split

Package.swift +10 −2
@@ -8,7 +8,10 @@ let package = Package(
8 .library(name: "OrgCore", targets: ["OrgCore"]), 8 .library(name: "OrgCore", targets: ["OrgCore"]),
9 .library(name: "OrgDocument", targets: ["OrgDocument"]), 9 .library(name: "OrgDocument", targets: ["OrgDocument"]),
10 .library(name: "OrgIndex", targets: ["OrgIndex"]), 10 .library(name: "OrgIndex", targets: ["OrgIndex"]),
11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]) 11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]),
12 .library(name: "OrgPresentation", targets: ["OrgPresentation"]),
13 .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"]),
14 .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"])
12 ], 15 ],
13 dependencies: [ 16 dependencies: [
14 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") 17 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -18,9 +21,14 @@ let package = Package(
18 .target(name: "OrgDocument", dependencies: ["OrgCore"]), 21 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
19 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]), 22 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
20 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]), 23 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]),
24 .target(name: "OrgPresentation", dependencies: ["OrgCore"]),
25 .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]),
26 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
21 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 27 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
22 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 28 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
23 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 29 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
24 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]) 30 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
31 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
32 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"])
25 ] 33 ]
26) 34)
Sources/OrgDocument/ViewState.swift +13 −2
@@ -22,11 +22,22 @@ public struct ViewState: Sendable, Equatable {
22 22
23 /// Drops folds that no longer sit at the start of a heading. 23 /// Drops folds that no longer sit at the start of a heading.
24 public func pruned(to tree: OrgTree) -> ViewState { 24 public func pruned(to tree: OrgTree) -> ViewState {
25 let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)) 25 guard !folds.isEmpty else { return self }
26 return ViewState(selection: selection, folds: folds.intersection(headings)) 26 return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
27 } 27 }
28} 28}
29 29
30/// Descends only through the sections containing `offset`.
31func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
32 var node = root
33 while let child = node.children.first(where: { $0.range.contains(offset) }) {
34 if child.kind == .heading { return child.range.lowerBound == offset }
35 guard child.kind == .section || child.kind == .zerothSection else { return false }
36 node = child
37 }
38 return false
39}
40
30/// An offset before an edit stays put; one at the start of a replaced range stays at its start; 41/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
31/// one inside it, at its end, or at an insertion point moves past the replacement. 42/// one inside it, at its end, or at an insertion point moves past the replacement.
32func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int { 43func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
Sources/OrgEditorAppKit/OrgEditor.swift added +347
@@ -0,0 +1,347 @@
1#if os(macOS)
2import AppKit
3import OrgCore
4import OrgDocument
5import OrgPresentation
6
7/// Hidden ranges, read by TextKit's enumeration callbacks, which may run off the main thread.
8final class HiddenRanges: @unchecked Sendable {
9 private let lock = NSLock()
10 private var ranges: [Range<Int>] = []
11
12 func set(_ new: [Range<Int>]) {
13 lock.withLock { ranges = new }
14 }
15
16 var all: [Range<Int>] {
17 lock.withLock { ranges }
18 }
19
20 func covers(_ start: Int, _ end: Int) -> Bool {
21 lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } }
22 }
23
24 func containing(_ offset: Int) -> Range<Int>? {
25 lock.withLock { ranges.first { $0.lowerBound <= offset && offset < $0.upperBound } }
26 }
27}
28
29/// Folded heading starts, read by the layout-fragment callback.
30final class FoldedHeadings: @unchecked Sendable {
31 private let lock = NSLock()
32 private var starts: Set<Int> = []
33
34 func set(_ new: Set<Int>) {
35 lock.withLock { starts = new }
36 }
37
38 func contains(_ offset: Int) -> Bool {
39 lock.withLock { starts.contains(offset) }
40 }
41}
42
43/// A TextKit 2 text view over a `DocumentState`. The text storage mirrors the document; every
44/// character edit goes through `DocumentState.apply`, and only the affected region is restyled.
45@MainActor
46public final class OrgEditor: NSObject {
47 public let textView: NSTextView
48 public private(set) var document: DocumentState
49 public private(set) var view = ViewState()
50 /// Called with the time from a character edit to restyled text.
51 public var onEditTiming: ((Duration) -> Void)?
52
53 let hidden = HiddenRanges()
54 let folded = FoldedHeadings()
55 let theme = Theme()
56 private var isLoading = false
57 private var previousSelection = 0
58
59 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
60 self.document = document
61 textView = NSTextView(usingTextLayoutManager: true)
62 super.init()
63 textView.frame = frame
64 textView.isRichText = false
65 textView.allowsUndo = true
66 textView.isAutomaticQuoteSubstitutionEnabled = false
67 textView.isAutomaticDashSubstitutionEnabled = false
68 textView.isAutomaticTextReplacementEnabled = false
69 textView.isAutomaticSpellingCorrectionEnabled = false
70 textView.font = theme.font
71 textView.typingAttributes = theme.base
72 textView.isVerticallyResizable = true
73 textView.autoresizingMask = [.width]
74 textView.textContainer?.widthTracksTextView = true
75 textView.delegate = self
76 textView.textStorage?.delegate = self
77 textLayoutManager.delegate = self
78 textContentStorage.delegate = self
79
80 isLoading = true
81 textView.string = document.text
82 isLoading = false
83 restyleOutsideEditing(0..<utf16Length)
84 }
85
86 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
87 var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
88 var utf16Length: Int { (textView.string as NSString).length }
89
90 /// A scroll view holding the text view, ready for a window.
91 public func makeScrollView() -> NSScrollView {
92 let scroll = NSScrollView(frame: textView.frame)
93 scroll.hasVerticalScroller = true
94 scroll.documentView = textView
95 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
96 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
97 return scroll
98 }
99
100 // MARK: - Saving
101
102 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and
103 /// folds are mapped through the change.
104 @discardableResult
105 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
106 let before = document.text
107 let outcome = try saver.save(&document, to: url)
108 if document.text != before {
109 let edits = lineEdits(from: before, to: document.text)
110 isLoading = true
111 textView.string = document.text
112 isLoading = false
113 restyleOutsideEditing(0..<utf16Length)
114 setFolds(view.mapped(through: edits).folds)
115 }
116 return outcome
117 }
118
119 // MARK: - Folding
120
121 public func setFolds(_ folds: Set<Int>) {
122 view.folds = folds
123 view = view.pruned(to: document.tree)
124 folded.set(view.folds)
125 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
126 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
127 textView.needsDisplay = true
128 let caret = textView.selectedRange().location
129 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
130 }
131
132 /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
133 @discardableResult
134 public func toggleFoldAtCaret() -> Bool {
135 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
136 var folds = view.folds
137 if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
138 setFolds(folds)
139 return true
140 }
141
142 /// Folds every heading, or unfolds everything if anything is folded.
143 public func toggleAllFolds() {
144 setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
145 }
146
147 private func setCaret(_ offset: Int) {
148 let clamped = max(0, min(offset, utf16Length))
149 previousSelection = clamped
150 textView.setSelectedRange(NSRange(location: clamped, length: 0))
151 }
152
153 // MARK: - Styling
154
155 private func restyleOutsideEditing(_ range: Range<Int>) {
156 guard let storage = textView.textStorage else { return }
157 storage.beginEditing()
158 restyle(range, in: storage)
159 storage.endEditing()
160 }
161
162 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
163 /// attribute changes are allowed and characters must not change.
164 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
165 guard !range.isEmpty else { return }
166 let whole = NSRange(range)
167 storage.setAttributes(theme.base, range: whole)
168 for run in Presentation.indents(document.tree, in: range) {
169 guard let clipped = clip(run.range, to: range) else { continue }
170 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
171 }
172 for run in Presentation.styles(document.tree, in: range) {
173 guard let clipped = clip(run.range, to: range) else { continue }
174 theme.apply(run.role, to: storage, range: clipped)
175 }
176 }
177
178 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
179 let clipped = run.clamped(to: range)
180 return clipped.isEmpty ? nil : NSRange(clipped)
181 }
182
183 /// The region an edit affects in one version of the tree: inside the innermost section
184 /// holding the edited lines (plus the line after), every element that overlaps them or the
185 /// line on either side. A changed heading line, or an added or removed heading, widens it
186 /// to that section's whole own body. Run on the old and the new tree, the union covers
187 /// elements that grew and elements that shrank.
188 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
189 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
190 let lineRange = lines.location..<(lines.location + lines.length)
191 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
192 var container = tree.root
193 while let child = container.children.first(where: {
194 ($0.kind == .section || $0.kind == .zerothSection)
195 && $0.range.lowerBound <= lineRange.lowerBound && lineRange.upperBound <= $0.range.upperBound
196 }) {
197 container = child
198 }
199 var lower = lineRange.lowerBound
200 var upper = lineRange.upperBound
201 let children = container.children
202 for child in children where child.range.overlaps(around) {
203 lower = min(lower, child.range.lowerBound)
204 upper = max(upper, child.range.upperBound)
205 }
206 let heading = children.first { $0.kind == .heading }
207 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
208 lower = min(lower, container.range.lowerBound)
209 upper = max(upper, children.first { $0.kind == .section }?.range.lowerBound ?? container.range.upperBound)
210 }
211 return lower..<min(upper, text.length)
212 }
213
214 // MARK: - Editing
215
216 fileprivate func storageEdited(range: NSRange, delta: Int) {
217 guard !isLoading, let storage = textView.textStorage else { return }
218 let clock = ContinuousClock()
219 let start = clock.now
220 let replacement = (storage.string as NSString).substring(with: range)
221 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
222 let oldText = document.text as NSString
223 let newText = storage.string as NSString
224 let newEnd = edit.range.lowerBound + replacement.utf16.count
225 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
226 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
227 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
228 do {
229 try document.apply([edit], baseRevision: document.revision)
230 } catch {
231 assertionFailure("text view and document disagree: \(error)")
232 return
233 }
234 let oldHidden = hidden.all
235 view = view.mapped(through: [edit]).pruned(to: document.tree)
236 folded.set(view.folds)
237 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
238 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
239 let delta = edit.replacement.utf16.count - edit.range.count
240 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
241 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
242 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
243 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
244 }
245 onEditTiming?(clock.now - start)
246 }
247
248 /// Whether a heading line touches `start..<end` or starts right after it, where joining
249 /// lines could take it away.
250 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
251 let length = min(end + 1, text.length) - start
252 guard length >= 0 else { return false }
253 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
254 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
255 let stars = line.prefix { $0 == "*" }
256 let after = line.dropFirst(stars.count)
257 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
258 }
259 }
260
261 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
262 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
263 return mapped
264 }
265
266 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
267 /// back jumps to the end of the heading line.
268 fileprivate func selectionChanged() {
269 let selection = textView.selectedRange()
270 defer { previousSelection = selection.location }
271 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
272 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
273 }
274}
275
276extension OrgEditor: NSTextViewDelegate {
277 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
278 switch selector {
279 case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
280 case #selector(NSResponder.insertBacktab(_:)):
281 toggleAllFolds()
282 return true
283 default: return false
284 }
285 }
286
287 public func textViewDidChangeSelection(_ notification: Notification) {
288 selectionChanged()
289 }
290}
291
292extension OrgEditor: NSTextStorageDelegate {
293 public nonisolated func textStorage(
294 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
295 ) {
296 guard editedMask.contains(.editedCharacters) else { return }
297 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
298 }
299}
300
301extension OrgEditor: NSTextContentStorageDelegate {
302 /// Folded text isn't enumerated, so it isn't laid out or drawn.
303 public nonisolated func textContentManager(
304 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
305 ) -> Bool {
306 guard let range = textElement.elementRange else { return true }
307 let documentStart = textContentManager.documentRange.location
308 let start = textContentManager.offset(from: documentStart, to: range.location)
309 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
310 return !hidden.covers(start, end)
311 }
312}
313
314extension OrgEditor: NSTextLayoutManagerDelegate {
315 public nonisolated func textLayoutManager(
316 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
317 ) -> NSTextLayoutFragment {
318 let range = textElement.elementRange
319 if let range, let manager = textLayoutManager.textContentManager {
320 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
321 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
322 }
323 return NSTextLayoutFragment(textElement: textElement, range: range)
324 }
325}
326
327/// A heading line drawn with an ellipsis after it, like org's folded headings.
328final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
329 override func draw(at point: CGPoint, in context: CGContext) {
330 super.draw(at: point, in: context)
331 guard let line = textLineFragments.first else { return }
332 let bounds = line.typographicBounds
333 let ellipsis = NSAttributedString(string: " …", attributes: [
334 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
335 .foregroundColor: NSColor.secondaryLabelColor,
336 ])
337 NSGraphicsContext.saveGraphicsState()
338 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
339 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
340 NSGraphicsContext.restoreGraphicsState()
341 }
342
343 override var renderingSurfaceBounds: CGRect {
344 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
345 }
346}
347#endif
Sources/OrgEditorAppKit/Theme.swift added +74
@@ -0,0 +1,74 @@
1#if os(macOS)
2import AppKit
3import OrgPresentation
4
5/// Attributes for each style role. A fixed theme for now; the app's theme settings replace it.
6@MainActor
7struct Theme {
8 let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
9
10 var base: [NSAttributedString.Key: Any] {
11 [.font: font, .foregroundColor: NSColor.textColor]
12 }
13
14 /// Width of one column of the monospaced font.
15 var column: CGFloat {
16 ("0" as NSString).size(withAttributes: [.font: font]).width
17 }
18
19 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
20 let style = NSMutableParagraphStyle()
21 style.firstLineHeadIndent = CGFloat(firstLine) * column
22 style.headIndent = CGFloat(wrapped) * column
23 return style
24 }
25
26 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
27 switch role {
28 case .heading(let level):
29 let size = font.pointSize + CGFloat(max(0, 4 - level))
30 storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold), range: range)
31 case .leadingStars:
32 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
33 case .todo:
34 storage.addAttributes([.foregroundColor: NSColor.systemRed, .font: bold(storage, range)], range: range)
35 case .done:
36 storage.addAttributes([.foregroundColor: NSColor.systemGreen, .font: bold(storage, range)], range: range)
37 case .priority:
38 storage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: range)
39 case .tags, .comment, .keyword, .metadata:
40 storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range)
41 case .bold:
42 storage.addAttribute(.font, value: bold(storage, range), range: range)
43 case .italic:
44 storage.addAttribute(.font, value: NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .italicFontMask), range: range)
45 case .underline:
46 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
47 case .strikeThrough:
48 storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
49 case .verbatim, .code, .inlineSource:
50 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
51 case .markup, .linkMarkup, .blockDelimiter:
52 storage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: range)
53 case .link:
54 storage.addAttributes([.foregroundColor: NSColor.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: range)
55 case .timestamp:
56 storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range)
57 case .footnote, .cookie, .target, .macro, .latex:
58 storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range)
59 case .block:
60 storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range)
61 case .table, .rule:
62 break
63 }
64 }
65
66 private func currentFont(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
67 storage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont ?? font
68 }
69
70 private func bold(_ storage: NSTextStorage, _ range: NSRange) -> NSFont {
71 NSFontManager.shared.convert(currentFont(storage, range), toHaveTrait: .boldFontMask)
72 }
73}
74#endif
Sources/OrgPresentation/Presentation.swift added +171
@@ -0,0 +1,171 @@
1import OrgCore
2
3/// What a range of text is, for styling. Platform adapters map roles to attributes.
4public enum StyleRole: Hashable, Sendable {
5 /// A whole heading line.
6 case heading(level: Int)
7 /// All stars of a heading but the last, hidden as in `org-hide-leading-stars`.
8 case leadingStars
9 case todo, done, priority, tags
10 case bold, italic, underline, strikeThrough, verbatim, code
11 /// Emphasis markers.
12 case markup
13 case link
14 /// Brackets and the target of a described link.
15 case linkMarkup
16 case timestamp, footnote, cookie, target, macro, latex, inlineSource
17 case comment, keyword
18 /// Planning lines, drawers, property drawers, clock lines.
19 case metadata
20 case block, blockDelimiter, table, rule
21}
22
23public struct StyleRun: Sendable, Equatable {
24 public let range: Range<Int>
25 public let role: StyleRole
26}
27
28/// org-indent: how many columns a run of whole lines starts in.
29public struct IndentRun: Sendable, Equatable {
30 public let range: Range<Int>
31 public let firstLine: Int
32 /// Continuation lines of a wrapped paragraph.
33 public let wrapped: Int
34}
35
36public enum Presentation {
37 /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
38 /// them in order lets inner roles win.
39 public static func styles(_ tree: OrgTree, in range: Range<Int>) -> [StyleRun] {
40 var runs: [StyleRun] = []
41 visit(tree.root, range, tree.settings, &runs)
42 return runs
43 }
44
45 private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ runs: inout [StyleRun]) {
46 if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
47 switch node.kind {
48 case .heading:
49 for token in node.tokens {
50 switch token.kind {
51 case .stars where token.range.count > 1:
52 runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
53 case .todoKeyword:
54 runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
55 case .priority:
56 runs.append(StyleRun(range: token.range, role: .priority))
57 case .tags:
58 runs.append(StyleRun(range: token.range, role: .tags))
59 default:
60 break
61 }
62 }
63 case .link:
64 let described = node.children.contains { $0.kind == .linkDescription }
65 for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
66 runs.append(StyleRun(range: token.range, role: .linkMarkup))
67 }
68 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
69 for token in node.tokens where token.kind == .marker {
70 runs.append(StyleRun(range: token.range, role: .markup))
71 }
72 case .block, .dynamicBlock:
73 let lines = node.tokens.filter { $0.kind == .text }
74 for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
75 runs.append(StyleRun(range: token.range, role: .blockDelimiter))
76 }
77 default:
78 break
79 }
80 for child in node.children where child.range.overlaps(range) {
81 visit(child, range, settings, &runs)
82 }
83 }
84
85 static func role(of node: SyntaxNode) -> StyleRole? {
86 switch node.kind {
87 case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
88 case .bold: .bold
89 case .italic: .italic
90 case .underline: .underline
91 case .strikeThrough: .strikeThrough
92 case .verbatim: .verbatim
93 case .code: .code
94 case .link: .link
95 case .timestamp: .timestamp
96 case .footnoteReference, .footnoteDefinition: .footnote
97 case .statisticsCookie: .cookie
98 case .target: .target
99 case .macro: .macro
100 case .latexFragment: .latex
101 case .inlineSourceBlock: .inlineSource
102 case .comment: .comment
103 case .keyword, .affiliatedKeyword: .keyword
104 case .planning, .propertyDrawer, .drawer, .clock: .metadata
105 case .block, .dynamicBlock, .fixedWidth: .block
106 case .table: .table
107 case .horizontalRule: .rule
108 default: nil
109 }
110 }
111
112 /// Heading lines start at column 0, with wrapped title lines under the title; a section's
113 /// own body starts one column past its stars.
114 public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
115 var runs: [IndentRun] = []
116 collectIndents(tree.root, range, &runs)
117 return runs
118 }
119
120 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
121 for section in node.children where section.kind == .section && section.range.overlaps(range) {
122 let children = section.children
123 guard let heading = children.first(where: { $0.kind == .heading }) else { continue }
124 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
125 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
126 let bodyEnd = children.first { $0.kind == .section }?.range.lowerBound ?? section.range.upperBound
127 if heading.range.upperBound < bodyEnd {
128 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
129 }
130 collectIndents(section, range, &runs)
131 }
132 }
133
134 /// Ranges to hide for folded headings, given by their start offsets: from the end of the
135 /// heading line to the end of its section. Sorted, without nested duplicates.
136 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
137 guard !folds.isEmpty else { return [] }
138 var ranges: [Range<Int>] = []
139 collectHidden(tree.root, folds, &ranges)
140 return ranges
141 }
142
143 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
144 for section in node.children where section.kind == .section {
145 guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
146 if folds.contains(heading.range.lowerBound) {
147 if heading.range.upperBound < section.range.upperBound {
148 ranges.append(heading.range.upperBound..<section.range.upperBound)
149 }
150 } else {
151 collectHidden(section, folds, &ranges)
152 }
153 }
154 }
155
156 /// Start offset of the heading whose line contains `offset`.
157 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
158 var node = tree.root
159 while let child = node.children.first(where: { $0.range.contains(offset) }) {
160 if child.kind == .heading { return child.range.lowerBound }
161 guard child.kind == .section || child.kind == .zerothSection else { return nil }
162 node = child
163 }
164 return nil
165 }
166
167 /// Start offsets of every heading, for folding everything.
168 public static func headings(_ tree: OrgTree) -> [Int] {
169 tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
170 }
171}
Sources/OrgstarSpike/main.swift added +89
@@ -0,0 +1,89 @@
1#if os(macOS)
2import AppKit
3import OrgDocument
4import OrgEditorAppKit
5
6/// A throwaway app for trying the editor by hand: `swift run OrgstarSpike path/to/file.org`.
7/// TAB on a heading folds it, Shift-TAB folds everything, ⌘S saves. Edit timings are printed
8/// every 50 edits.
9@MainActor
10final class SpikeDelegate: NSObject, NSApplicationDelegate {
11 let url: URL
12 var editor: OrgEditor?
13 var window: NSWindow?
14 var timings: [Duration] = []
15
16 init(url: URL) {
17 self.url = url
18 }
19
20 func applicationDidFinishLaunching(_ notification: Notification) {
21 let bytes = (try? [UInt8](Data(contentsOf: url))) ?? []
22 let editor = OrgEditor(document: DocumentState(bytes: bytes), frame: NSRect(x: 0, y: 0, width: 900, height: 1000))
23 editor.onEditTiming = { [weak self] in self?.record($0) }
24 let window = NSWindow(
25 contentRect: NSRect(x: 100, y: 100, width: 900, height: 1000),
26 styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false
27 )
28 window.title = url.lastPathComponent
29 window.contentView = editor.makeScrollView()
30 window.makeKeyAndOrderFront(nil)
31 window.makeFirstResponder(editor.textView)
32 self.editor = editor
33 self.window = window
34 buildMenu()
35 NSApp.activate()
36 }
37
38 func buildMenu() {
39 let main = NSMenu()
40 let appItem = NSMenuItem()
41 appItem.submenu = NSMenu()
42 appItem.submenu?.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
43 let fileItem = NSMenuItem()
44 fileItem.submenu = NSMenu(title: "File")
45 fileItem.submenu?.addItem(withTitle: "Save", action: #selector(save), keyEquivalent: "s").target = self
46 let editItem = NSMenuItem()
47 editItem.submenu = NSMenu(title: "Edit")
48 editItem.submenu?.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
49 editItem.submenu?.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
50 editItem.submenu?.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
51 editItem.submenu?.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
52 editItem.submenu?.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
53 editItem.submenu?.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
54 [appItem, fileItem, editItem].forEach(main.addItem)
55 NSApp.mainMenu = main
56 }
57
58 @objc func save() {
59 guard let editor else { return }
60 let recovery = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
61 .appendingPathComponent("Orgstar/Recovery")
62 let saver = Saver(fileSystem: CoordinatedFileSystem(), recovery: FileRecoveryStore(directory: recovery))
63 do {
64 print("save:", try editor.save(using: saver, to: url))
65 } catch {
66 print("save failed:", error)
67 }
68 }
69
70 func record(_ duration: Duration) {
71 timings.append(duration)
72 guard timings.count % 50 == 0 else { return }
73 let sorted = timings.sorted()
74 print("edits: \(timings.count) p50 \(sorted[sorted.count / 2]) p95 \(sorted[sorted.count * 95 / 100])")
75 }
76
77 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
78}
79
80guard CommandLine.arguments.count > 1 else {
81 print("usage: OrgstarSpike path/to/file.org")
82 exit(2)
83}
84let app = NSApplication.shared
85app.setActivationPolicy(.regular)
86let delegate = SpikeDelegate(url: URL(fileURLWithPath: CommandLine.arguments[1]))
87app.delegate = delegate
88app.run()
89#endif
Tests/OrgEditorAppKitTests/EditorTests.swift added +248
@@ -0,0 +1,248 @@
1import AppKit
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgEditorAppKit
6
7/// An editor in an offscreen window, laid out.
8@MainActor
9final class Harness {
10 let editor: OrgEditor
11 let window: NSWindow
12
13 init(_ text: String) {
14 _ = NSApplication.shared
15 editor = OrgEditor(document: DocumentState(bytes: Array(text.utf8)))
16 window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 800), styleMask: [.titled], backing: .buffered, defer: false)
17 window.contentView = editor.makeScrollView()
18 window.makeFirstResponder(editor.textView)
19 layout()
20 }
21
22 var textView: NSTextView { editor.textView }
23 var string: String { textView.string }
24
25 func layout() {
26 let manager = editor.textLayoutManager
27 manager.ensureLayout(for: manager.documentRange)
28 }
29
30 /// Text of each laid-out paragraph.
31 var visibleLines: [String] {
32 layout()
33 let manager = editor.textLayoutManager
34 var lines: [String] = []
35 manager.enumerateTextLayoutFragments(from: manager.documentRange.location, options: [.ensuresLayout]) { fragment in
36 let content = manager.textContentManager!
37 let start = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.location)
38 let end = content.offset(from: content.documentRange.location, to: fragment.rangeInElement.endLocation)
39 lines.append((self.string as NSString).substring(with: NSRange(location: start, length: end - start)))
40 return true
41 }
42 return lines.filter { !$0.isEmpty }
43 }
44
45 func caret(at offset: Int) {
46 textView.setSelectedRange(NSRange(location: offset, length: 0))
47 }
48
49 var caret: Int { textView.selectedRange().location }
50
51 func offset(of line: String) -> Int {
52 (string as NSString).range(of: line).location
53 }
54
55 func checkInSync() {
56 #expect(editor.document.text == string)
57 #expect(editor.document.tree.green == OrgParser.parse(string).green)
58 }
59}
60
61let outline = "* a\nbody\n** b\nmore\n* c\ntext\n"
62
63@MainActor
64struct FoldingTests {
65 @Test func foldingHidesTheSubtree() {
66 let h = Harness(outline)
67 #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
68 h.caret(at: 1)
69 #expect(h.editor.toggleFoldAtCaret())
70 #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
71 h.editor.toggleFoldAtCaret()
72 #expect(h.visibleLines.count == 6)
73 }
74
75 @Test func tabFoldsOnlyOnHeadings() {
76 let h = Harness(outline)
77 h.caret(at: 5)
78 #expect(!h.editor.toggleFoldAtCaret())
79 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
80 #expect(h.visibleLines == ["* a\n", "* c\n"])
81 }
82
83 @Test func caretMovesOverFoldedText() {
84 let h = Harness(outline)
85 h.caret(at: 1)
86 h.editor.toggleFoldAtCaret()
87 h.caret(at: 0)
88 h.textView.moveDown(nil)
89 #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
90 h.caret(at: 3)
91 h.textView.moveRight(nil)
92 #expect(h.caret == h.offset(of: "* c"))
93 h.textView.moveLeft(nil)
94 #expect(h.caret == 3)
95 }
96
97 @Test func copyingAcrossAFoldIncludesHiddenText() {
98 let h = Harness(outline)
99 h.caret(at: 1)
100 h.editor.toggleFoldAtCaret()
101 h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
102 let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
103 defer { pasteboard.releaseGlobally() }
104 #expect(h.textView.writeSelection(to: pasteboard, types: h.textView.writablePasteboardTypes))
105 #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n")
106 }
107
108 @Test func foldsFollowEdits() {
109 let h = Harness(outline)
110 h.caret(at: h.offset(of: "* c") + 1)
111 h.editor.toggleFoldAtCaret()
112 h.caret(at: 0)
113 h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
114 #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
115 h.checkInSync()
116 }
117}
118
119@MainActor
120struct EditingTests {
121 @Test func typingKeepsTheDocumentInSync() {
122 let h = Harness(outline)
123 h.caret(at: h.offset(of: "body") + 4)
124 h.textView.insertText(" *bold*", replacementRange: h.textView.selectedRange())
125 h.textView.insertNewline(nil)
126 h.textView.insertText("* new heading", replacementRange: h.textView.selectedRange())
127 h.textView.deleteBackward(nil)
128 h.checkInSync()
129 #expect(h.editor.document.isDirty)
130 }
131
132 @Test func inputMethodComposition() {
133 let h = Harness("* a\n")
134 h.caret(at: 3)
135 h.textView.setMarkedText("か", selectedRange: NSRange(location: 1, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
136 #expect(h.textView.hasMarkedText())
137 h.textView.setMarkedText("かん", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
138 h.textView.insertText("漢", replacementRange: NSRange(location: NSNotFound, length: 0))
139 #expect(!h.textView.hasMarkedText())
140 #expect(h.string == "* a漢\n")
141 h.checkInSync()
142 }
143
144 @Test func undoKeepsTheDocumentInSync() {
145 let h = Harness(outline)
146 h.caret(at: 3)
147 h.textView.insertText("xyz", replacementRange: h.textView.selectedRange())
148 h.textView.breakUndoCoalescing()
149 h.textView.undoManager?.undo()
150 #expect(h.string == outline)
151 h.checkInSync()
152 }
153
154 @Test func styling() throws {
155 let h = Harness("** b\nbody\n")
156 let storage = try #require(h.textView.textStorage)
157 #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .clear)
158 let style = try #require(storage.attribute(.paragraphStyle, at: 6, effectiveRange: nil) as? NSParagraphStyle)
159 #expect(style.firstLineHeadIndent == 3 * h.editor.theme.column)
160 h.caret(at: 0)
161 h.textView.insertText("*", replacementRange: NSRange(location: 0, length: 0))
162 let deeper = try #require(storage.attribute(.paragraphStyle, at: 7, effectiveRange: nil) as? NSParagraphStyle)
163 #expect(deeper.firstLineHeadIndent == 4 * h.editor.theme.column)
164 }
165
166 @Test func accessibilityReadsTheWholeText() {
167 let h = Harness(outline)
168 h.caret(at: 1)
169 h.editor.toggleFoldAtCaret()
170 #expect(h.textView.accessibilityValue() as? String == outline)
171 h.textView.setSelectedRange(NSRange(location: 0, length: 3))
172 #expect(h.textView.accessibilitySelectedText() == "* a")
173 #expect(h.textView.accessibilityLine(for: h.offset(of: "* c")) >= 1)
174 }
175
176 @Test func saveWritesAndClearsDirty() throws {
177 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
178 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
179 defer { try? FileManager.default.removeItem(at: folder) }
180 let file = folder.appendingPathComponent("a.org")
181 try Data(outline.utf8).write(to: file)
182 let h = Harness(outline)
183 h.textView.insertText("x", replacementRange: NSRange(location: 0, length: 0))
184 let saver = Saver(fileSystem: CoordinatedFileSystem(), recovery: FileRecoveryStore(directory: folder.appendingPathComponent("r")))
185 #expect(try h.editor.save(using: saver, to: file) == .saved)
186 #expect(!h.editor.document.isDirty)
187 #expect(try String(contentsOf: file, encoding: .utf8) == "x" + outline)
188 }
189}
190
191@MainActor
192struct RestyleTests {
193 func indent(_ h: Harness, at line: String) -> CGFloat? {
194 let style = h.textView.textStorage?.attribute(.paragraphStyle, at: h.offset(of: line), effectiveRange: nil) as? NSParagraphStyle
195 return style.map { $0.firstLineHeadIndent / h.editor.theme.column }
196 }
197
198 @Test func newHeadingReindentsTheLinesItTakesOver() {
199 let h = Harness("* a\none\ntwo\nthree\n")
200 h.textView.insertText("** b\n", replacementRange: NSRange(location: h.offset(of: "two"), length: 0))
201 #expect(indent(h, at: "one") == 2)
202 #expect(indent(h, at: "three") == 3)
203 h.checkInSync()
204 }
205
206 @Test func joiningAwayAHeadingReindentsItsBody() {
207 let h = Harness("* a\none\n** b\ntwo\nthree\n")
208 h.textView.insertText("", replacementRange: NSRange(location: h.offset(of: "** b") - 1, length: 1))
209 #expect(indent(h, at: "three") == 2)
210 h.checkInSync()
211 }
212
213 @Test func typingASpaceAfterAStarMakesAHeading() {
214 let h = Harness("* a\n*x\nbody\nmore\n")
215 h.textView.insertText(" ", replacementRange: NSRange(location: h.offset(of: "*x") + 1, length: 0))
216 #expect(indent(h, at: "more") == 2)
217 h.checkInSync()
218 }
219}
220
221@MainActor
222struct RestyleFuzzTests {
223 /// After each random edit, the restyled text must equal a fresh editor's full styling. Set
224 /// `ORGSTAR_RESTYLE_EDITS` and `ORGSTAR_FUZZ_SEED` for longer runs.
225 @Test func incrementalRestyleEqualsFullRestyle() throws {
226 let environment = ProcessInfo.processInfo.environment
227 var state = UInt64(environment["ORGSTAR_FUZZ_SEED"] ?? "") ?? 20261006
228 let count = Int(environment["ORGSTAR_RESTYLE_EDITS"] ?? "") ?? 300
229 func next(_ bound: Int) -> Int {
230 state = state &* 6364136223846793005 &+ 1442695040888963407
231 return Int((state >> 33) % UInt64(bound))
232 }
233 let pieces = ["* ", "** ", "*", " ", "\n", "x", "*b*", "TODO ", "- ", "| a |", "#+begin_src\n", "#+end_src\n", ":PROPERTIES:\n", ":END:\n", "[[l][d]]", "<2026-10-04 Sun>"]
234 let h = Harness("* a\nbody *b*\n** b\n- item\nmore\n* c\ntext\n")
235 for n in 0..<count {
236 let length = (h.string as NSString).length
237 let start = next(length + 1)
238 let end = min(length, start + next(4))
239 let replacement = next(3) == 0 ? "" : pieces[next(pieces.count)]
240 h.textView.insertText(replacement, replacementRange: NSRange(location: start, length: end - start))
241 let fresh = Harness(h.string)
242 let same = h.textView.textStorage!.isEqual(to: fresh.textView.textStorage!)
243 #expect(same, "edit \(n): \(start)..<\(end) \(replacement.debugDescription) -> \(h.string.debugDescription)")
244 if !same { break }
245 }
246 h.checkInSync()
247 }
248}
Tests/OrgPresentationTests/PresentationTests.swift added +78
@@ -0,0 +1,78 @@
1import OrgCore
2import Testing
3@testable import OrgPresentation
4
5func roles(_ text: String) -> [String] {
6 let tree = OrgParser.parse(text)
7 let utf16 = Array(text.utf16)
8 return Presentation.styles(tree, in: 0..<utf16.count).map { run in
9 "\(run.role):\(String(decoding: utf16[run.range], as: UTF16.self))"
10 }
11}
12
13struct StyleTests {
14 @Test func headingParts() {
15 #expect(roles("** DONE [#A] Ship *it* :work:\n") == [
16 "heading(level: 2):** DONE [#A] Ship *it* :work:\n",
17 "leadingStars:*", "done:DONE", "priority:[#A]", "tags::work:",
18 "bold:*it*", "markup:*", "markup:*",
19 ])
20 }
21
22 @Test func describedLinksDimTheirTarget() {
23 #expect(roles("[[https://a.b][site]]\n") == [
24 "link:[[https://a.b][site]]", "linkMarkup:[[", "linkMarkup:https://a.b", "linkMarkup:][", "linkMarkup:]]",
25 ])
26 #expect(roles("[[https://a.b]]\n") == ["link:[[https://a.b]]", "linkMarkup:[[", "linkMarkup:]]"])
27 }
28
29 @Test func blocksAndMetadata() {
30 #expect(roles("#+begin_src sh\necho\n#+end_src\n") == [
31 "block:#+begin_src sh\necho\n#+end_src\n", "blockDelimiter:#+begin_src sh", "blockDelimiter:#+end_src",
32 ])
33 #expect(roles("* a\nSCHEDULED: <2026-10-04 Sun>\n").contains("metadata:SCHEDULED: <2026-10-04 Sun>\n"))
34 }
35
36 @Test func onlyRunsOverlappingTheRange() {
37 let text = "* a\nfirst *b*\n* c\nsecond *d*\n"
38 let tree = OrgParser.parse(text)
39 let runs = Presentation.styles(tree, in: 17..<28)
40 #expect(!runs.contains { $0.range == 10..<13 })
41 #expect(runs.contains { $0.range == 25..<28 && $0.role == .bold })
42 }
43}
44
45struct IndentTests {
46 @Test func bodiesIndentPastTheirStars() {
47 let text = "pre\n* a\nbody\n** b\nmore\n"
48 let runs = Presentation.indents(OrgParser.parse(text), in: 0..<text.utf16.count)
49 #expect(runs == [
50 IndentRun(range: 4..<8, firstLine: 0, wrapped: 2),
51 IndentRun(range: 8..<13, firstLine: 2, wrapped: 2),
52 IndentRun(range: 13..<18, firstLine: 0, wrapped: 3),
53 IndentRun(range: 18..<23, firstLine: 3, wrapped: 3),
54 ])
55 }
56}
57
58struct FoldTests {
59 let text = "* a\nbody\n** b\nmore\n* c\ntext\n"
60
61 @Test func hiddenRanges() {
62 let tree = OrgParser.parse(text)
63 #expect(Presentation.hiddenRanges(tree, folds: [0]) == [4..<19])
64 #expect(Presentation.hiddenRanges(tree, folds: [9]) == [14..<19])
65 #expect(Presentation.hiddenRanges(tree, folds: [0, 9]) == [4..<19])
66 #expect(Presentation.hiddenRanges(tree, folds: [19]) == [23..<28])
67 #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty)
68 }
69
70 @Test func headingContaining() {
71 let tree = OrgParser.parse(text)
72 #expect(Presentation.heading(containing: 2, in: tree) == 0)
73 #expect(Presentation.heading(containing: 3, in: tree) == 0)
74 #expect(Presentation.heading(containing: 5, in: tree) == nil)
75 #expect(Presentation.heading(containing: 10, in: tree) == 9)
76 #expect(Presentation.headings(tree) == [0, 9, 19])
77 }
78}
docs/plans/2026-10-04-editing-spike.md added +1183
@@ -0,0 +1,1183 @@
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```