krz/orgstar

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

Commit 53903567a6

53903567a65c44ccdf4ba4ff6ffa6d04721efea1

parent: 253ae9fa74

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:34 UTC

Add TextKit 2 editor with folding and incremental restyle

Layout: unified · split

Package.swift +5 −2
@@ -9,7 +9,8 @@ let package = Package(
99 .library(name: "OrgDocument", targets: ["OrgDocument"]),
1010 .library(name: "OrgIndex", targets: ["OrgIndex"]),
1111 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]),
12 .library(name: "OrgPresentation", targets: ["OrgPresentation"])
12 .library(name: "OrgPresentation", targets: ["OrgPresentation"]),
13 .library(name: "OrgEditorAppKit", targets: ["OrgEditorAppKit"])
1314 ],
1415 dependencies: [
1516 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -20,10 +21,12 @@ let package = Package(
2021 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
2122 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]),
2223 .target(name: "OrgPresentation", dependencies: ["OrgCore"]),
24 .target(name: "OrgEditorAppKit", dependencies: ["OrgDocument", "OrgPresentation"]),
2325 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
2426 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
2527 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
2628 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
27 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"])
29 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"]),
30 .testTarget(name: "OrgEditorAppKitTests", dependencies: ["OrgEditorAppKit"])
2831 ]
2932)
Sources/OrgDocument/ViewState.swift +13 −2
@@ -22,11 +22,22 @@ public struct ViewState: Sendable, Equatable {
2222
2323 /// Drops folds that no longer sit at the start of a heading.
2424 public func pruned(to tree: OrgTree) -> ViewState {
25 let headings = Set(tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound))
26 return ViewState(selection: selection, folds: folds.intersection(headings))
25 guard !folds.isEmpty else { return self }
26 return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
2727 }
2828}
2929
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
3041/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
3142/// one inside it, at its end, or at an insertion point moves past the replacement.
3243func 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
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}