krz/orgstar

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

Commit 34d9da1754

34d9da1754968de3302011c7e5328aa53829ed93

parent: d9a8722155

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:54 UTC

Cycle folds, apply #+STARTUP visibility and indent wrapped lines

Layout: unified · split

Sources/OrgDocument/ViewState.swift +13 −5
@@ -3,12 +3,15 @@ import OrgCore
33/// Per-window state for one document: selection and folds, in UTF-16 offsets.
44public struct ViewState: Sendable, Equatable {
55 public var selection: [Range<Int>]
6 /// Start offsets of folded headings.
6 /// Start offsets of headings whose whole subtree is folded.
77 public var folds: Set<Int>
8 /// Start offsets of headings whose own body is folded; their child headings stay visible.
9 public var bodyFolds: Set<Int>
810
9 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) {
11 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
1012 self.selection = selection
1113 self.folds = folds
14 self.bodyFolds = bodyFolds
1215 }
1316
1417 /// Maps through non-overlapping edits given in old coordinates.
@@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable {
1619 let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
1720 return ViewState(
1821 selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
19 folds: Set(folds.map { mapOffset($0, sorted) })
22 folds: Set(folds.map { mapOffset($0, sorted) }),
23 bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
2024 )
2125 }
2226
2327 /// Drops folds that no longer sit at the start of a heading.
2428 public func pruned(to tree: OrgTree) -> ViewState {
25 guard !folds.isEmpty else { return self }
26 return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) })
29 guard !folds.isEmpty || !bodyFolds.isEmpty else { return self }
30 return ViewState(
31 selection: selection,
32 folds: folds.filter { isHeadingStart($0, tree.root) },
33 bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) }
34 )
2735 }
2836}
2937
Sources/OrgEditorAppKit/OrgEditor.swift +101 −17
@@ -65,12 +65,13 @@ public final class OrgEditor: NSObject {
6565 /// Styled per run-loop turn afterwards.
6666 static let styleChunk = 200_000
6767
68 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
68 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
6969 self.document = document
7070 textView = NSTextView(usingTextLayoutManager: true)
7171 super.init()
7272 textView.frame = frame
7373 textView.isRichText = false
74 textView.isEditable = editable
7475 textView.allowsUndo = true
7576 textView.isAutomaticQuoteSubstitutionEnabled = false
7677 textView.isAutomaticDashSubstitutionEnabled = false
@@ -87,6 +88,7 @@ public final class OrgEditor: NSObject {
8788 textContentStorage.delegate = self
8889
8990 load(document.text)
91 if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
9092 }
9193
9294 /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -153,37 +155,77 @@ public final class OrgEditor: NSObject {
153155 if document.text != before {
154156 let edits = lineEdits(from: before, to: document.text)
155157 load(document.text)
156 setFolds(view.mapped(through: edits).folds)
158 let mapped = view.mapped(through: edits)
159 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
157160 }
158161 return outcome
159162 }
160163
164 // MARK: - Disk
165
166 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
167 /// its headings.
168 public func diskChanged(to bytes: [UInt8]) {
169 let edits: [TextEdit]
170 switch document.diskChanged(to: bytes) {
171 case .reloaded(let changes), .merged(let changes): edits = changes
172 case .unchanged, .conflict: return
173 }
174 let mapped = view.mapped(through: edits)
175 load(document.text)
176 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
177 }
178
161179 // MARK: - Folding
162180
163 public func setFolds(_ folds: Set<Int>) {
164 view.folds = folds
181 public var foldState: FoldState {
182 FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
183 }
184
185 private var globalVisibility: GlobalVisibility?
186
187 public func setFolds(_ state: FoldState) {
188 view.folds = state.folds
189 view.bodyFolds = state.bodyFolds
165190 view = view.pruned(to: document.tree)
166 folded.set(view.folds)
167 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
191 updateHidden()
168192 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
169193 textView.needsDisplay = true
170194 let caret = textView.selectedRange().location
171195 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
172196 }
173197
174 /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
198 private func updateHidden() {
199 folded.set(view.folds.union(view.bodyFolds))
200 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
201 }
202
203 /// TAB on a heading: folded, children, subtree. Returns false off a heading.
175204 @discardableResult
176 public func toggleFoldAtCaret() -> Bool {
205 public func cycleAtCaret() -> Bool {
177206 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
178 var folds = view.folds
179 if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
180 setFolds(folds)
207 setFolds(Presentation.cycle(heading, in: document.tree, foldState))
181208 return true
182209 }
183210
184 /// Folds every heading, or unfolds everything if anything is folded.
185 public func toggleAllFolds() {
186 setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
211 /// Shift-TAB: overview, contents, show all.
212 public func cycleGlobal() {
213 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
214 globalVisibility = visibility
215 setFolds(state)
216 }
217
218 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
219 public func reveal(_ offset: Int) {
220 let target = max(0, min(offset, utf16Length))
221 var state = foldState
222 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
223 state.folds.remove(entry.start)
224 state.bodyFolds.remove(entry.start)
225 }
226 setFolds(state)
227 setCaret(target)
228 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
187229 }
188230
189231 private func setCaret(_ offset: Int) {
@@ -207,9 +249,23 @@ public final class OrgEditor: NSObject {
207249 guard !range.isEmpty else { return }
208250 let whole = NSRange(range)
209251 storage.setAttributes(theme.base, range: whole)
252 let text = storage.string as NSString
210253 for run in Presentation.indents(document.tree, in: range) {
211254 guard let clipped = clip(run.range, to: range) else { continue }
212 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
255 guard run.firstLine == run.wrapped else {
256 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped)
257 continue
258 }
259 // Body lines wrap under their own text: past leading blanks, and past a list bullet
260 // and checkbox, as adaptive-wrap does in Emacs.
261 var location = clipped.location
262 while location < NSMaxRange(clipped) {
263 let line = text.lineRange(for: NSRange(location: location, length: 0))
264 let hanging = Self.hangingColumns(text.substring(with: line))
265 let lineRange = NSIntersectionRange(line, clipped)
266 storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange)
267 location = NSMaxRange(line)
268 }
213269 }
214270 for run in Presentation.styles(document.tree, in: range) {
215271 guard let clipped = clip(run.range, to: range) else { continue }
@@ -301,6 +357,34 @@ public final class OrgEditor: NSObject {
301357 onEditTiming?(clock.now - start)
302358 }
303359
360 /// Columns a wrapped continuation of `line` should start past the line's own start.
361 nonisolated static func hangingColumns(_ line: String) -> Int {
362 var columns = 0
363 var rest = Substring(line)
364 while let c = rest.first, c == " " || c == "\t" {
365 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
366 rest = rest.dropFirst()
367 }
368 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
369 var bullet = 0
370 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
371 bullet = 1
372 } else {
373 let digits = rest.prefix { $0.isASCII && $0.isNumber }
374 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
375 bullet = digits.count + 1
376 }
377 }
378 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
379 columns += bullet + 1
380 let afterBullet = rest.dropFirst(bullet + 1)
381 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
382 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
383 columns += 4
384 }
385 return columns
386 }
387
304388 /// Whether a heading line touches `start..<end` or starts right after it, where joining
305389 /// lines could take it away.
306390 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
@@ -332,9 +416,9 @@ public final class OrgEditor: NSObject {
332416extension OrgEditor: NSTextViewDelegate {
333417 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
334418 switch selector {
335 case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
419 case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
336420 case #selector(NSResponder.insertBacktab(_:)):
337 toggleAllFolds()
421 cycleGlobal()
338422 return true
339423 default: return false
340424 }
Sources/OrgPresentation/Folding.swift added +155
@@ -0,0 +1,155 @@
1import OrgCore
2
3/// Which headings are folded: whole subtrees, or only their own bodies.
4public struct FoldState: Sendable, Equatable {
5 public var folds: Set<Int>
6 public var bodyFolds: Set<Int>
7
8 public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
9 self.folds = folds
10 self.bodyFolds = bodyFolds
11 }
12}
13
14/// org's global visibility states, cycled by Shift-TAB.
15public enum GlobalVisibility: Sendable, Equatable {
16 /// Top-level headings only.
17 case overview
18 /// Every heading, no bodies.
19 case contents
20 case showAll
21}
22
23public struct OutlineEntry: Sendable, Equatable {
24 /// Start offset of the heading line.
25 public let start: Int
26 public let level: Int
27 /// The title as written.
28 public let title: String
29 /// End of the heading's section.
30 public let end: Int
31 /// Index of the parent entry.
32 public let parent: Int?
33 public let isTopLevel: Bool
34}
35
36extension Presentation {
37 /// Every heading in document order.
38 public static func outline(_ tree: OrgTree) -> [OutlineEntry] {
39 var entries: [OutlineEntry] = []
40 collectOutline(tree.root, parent: nil, topLevel: true, &entries)
41 return entries
42 }
43
44 private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) {
45 for section in node.children where section.kind == .section {
46 guard let heading = section.firstChild(.heading) else { continue }
47 let index = entries.count
48 entries.append(OutlineEntry(
49 start: heading.range.lowerBound,
50 level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1,
51 title: heading.firstChild(.title)?.text ?? "",
52 end: section.range.upperBound,
53 parent: parent,
54 isTopLevel: topLevel
55 ))
56 collectOutline(section, parent: index, topLevel: false, &entries)
57 }
58 }
59
60 /// TAB on a heading: folded, then its children, then the whole subtree, then folded again.
61 /// A heading without children goes straight from folded to unfolded.
62 public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
63 let outline = outline(tree)
64 guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
65 let entry = outline[index]
66 let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start)
67 let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start)
68 var next = state
69 func unfoldSubtree() {
70 next.folds.subtract([heading] + descendants)
71 next.bodyFolds.subtract([heading] + descendants)
72 }
73 if state.folds.contains(heading) {
74 unfoldSubtree()
75 next.folds.formUnion(children)
76 } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) {
77 unfoldSubtree()
78 } else {
79 unfoldSubtree()
80 next.folds.insert(heading)
81 }
82 return next
83 }
84
85 /// Shift-TAB: overview, contents, show all, and around.
86 public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) {
87 let next: GlobalVisibility = switch current {
88 case .overview: .contents
89 case .contents: .showAll
90 case .showAll, nil: .overview
91 }
92 return (next, foldState(for: next, in: tree))
93 }
94
95 public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState {
96 let outline = outline(tree)
97 switch visibility {
98 case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start)))
99 case .contents: return FoldState(bodyFolds: Set(outline.map(\.start)))
100 case .showAll: return FoldState()
101 }
102 }
103
104 /// Headings of levels 1 through `levels` visible, no bodies.
105 public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState {
106 let outline = outline(tree)
107 return FoldState(
108 folds: Set(outline.filter { $0.level == levels }.map(\.start)),
109 bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start))
110 )
111 }
112
113 /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none.
114 /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`...
115 public static func startupFoldState(_ tree: OrgTree) -> FoldState? {
116 var result: FoldState?
117 for option in startupOptions(tree) {
118 switch option.lowercased() {
119 case "overview", "fold": result = foldState(for: .overview, in: tree)
120 case "content": result = foldState(for: .contents, in: tree)
121 case "showall", "nofold", "showeverything": result = FoldState()
122 case let word where word.hasPrefix("show") && word.hasSuffix("levels"):
123 if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 {
124 result = foldState(showingLevels: levels, in: tree)
125 }
126 default: break
127 }
128 }
129 return result
130 }
131
132 /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside
133 /// blocks and paragraphs is never read as a setting.
134 public static func startupOptions(_ tree: OrgTree) -> [String] {
135 var options: [String] = []
136 func visit(_ node: GreenNode) {
137 for case .node(let child) in node.children {
138 switch child.kind {
139 case .keyword:
140 let text = child.text
141 if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"),
142 let colon = text.firstIndex(of: ":") {
143 options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init)
144 }
145 case .document, .zerothSection, .section, .drawer:
146 visit(child)
147 default:
148 break
149 }
150 }
151 }
152 visit(tree.green)
153 return options
154 }
155}
Sources/OrgPresentation/Presentation.swift +16 −10
@@ -130,25 +130,31 @@ public enum Presentation {
130130 }
131131 }
132132
133 /// Ranges to hide for folded headings, given by their start offsets: from the end of the
134 /// heading line to the end of its section. Sorted, without nested duplicates.
135 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
136 guard !folds.isEmpty else { return [] }
133 /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
134 /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
135 /// Sorted, without nested duplicates.
136 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
137 guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
137138 var ranges: [Range<Int>] = []
138 collectHidden(tree.root, folds, &ranges)
139 collectHidden(tree.root, folds, bodyFolds, &ranges)
139140 return ranges
140141 }
141142
142 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
143 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
143144 for section in node.children where section.kind == .section {
144 guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
145 if folds.contains(heading.range.lowerBound) {
145 guard let heading = section.firstChild(.heading) else { continue }
146 let start = heading.range.lowerBound
147 if folds.contains(start) {
146148 if heading.range.upperBound < section.range.upperBound {
147149 ranges.append(heading.range.upperBound..<section.range.upperBound)
148150 }
149 } else {
150 collectHidden(section, folds, &ranges)
151 continue
152 }
153 if bodyFolds.contains(start) {
154 let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
155 if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) }
151156 }
157 collectHidden(section, folds, bodyFolds, &ranges)
152158 }
153159 }
154160
Tests/OrgEditorAppKitTests/EditorTests.swift +47 −7
@@ -1,6 +1,7 @@
11import AppKit
22import OrgCore
33import OrgDocument
4import OrgPresentation
45import Testing
56@testable import OrgEditorAppKit
67
@@ -77,16 +78,45 @@ struct FoldingTests {
7778 let h = Harness(outline)
7879 #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"])
7980 h.caret(at: 1)
80 #expect(h.editor.toggleFoldAtCaret())
81 #expect(h.editor.cycleAtCaret())
8182 #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
82 h.editor.toggleFoldAtCaret()
83 h.editor.cycleAtCaret()
84 #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"])
85 h.editor.cycleAtCaret()
8386 #expect(h.visibleLines.count == 6)
8487 }
8588
89 @Test func globalCycleAndStartup() {
90 let h = Harness(outline)
91 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
92 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
93 #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
94 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
95 #expect(h.visibleLines.count == 6)
96 #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
97 }
98
99 @Test func revealUnfoldsWhatHidesTheTarget() {
100 let h = Harness("#+STARTUP: overview\n" + outline)
101 let target = h.offset(of: "more")
102 h.editor.reveal(target)
103 #expect(h.visibleLines.contains("more\n"))
104 #expect(h.caret == target)
105 }
106
107 @Test func readOnlyAndReloadKeepFolds() {
108 let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false)
109 #expect(!editor.textView.isEditable)
110 editor.setFolds(FoldState(folds: [19]))
111 editor.diskChanged(to: Array(("new line\n" + outline).utf8))
112 #expect(editor.document.text == "new line\n" + outline)
113 #expect(editor.foldState.folds == [28])
114 }
115
86116 @Test func tabFoldsOnlyOnHeadings() {
87117 let h = Harness(outline)
88118 h.caret(at: 5)
89 #expect(!h.editor.toggleFoldAtCaret())
119 #expect(!h.editor.cycleAtCaret())
90120 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
91121 #expect(h.visibleLines == ["* a\n", "* c\n"])
92122 }
@@ -94,7 +124,7 @@ struct FoldingTests {
94124 @Test func caretMovesOverFoldedText() {
95125 let h = Harness(outline)
96126 h.caret(at: 1)
97 h.editor.toggleFoldAtCaret()
127 h.editor.cycleAtCaret()
98128 h.caret(at: 0)
99129 h.textView.moveDown(nil)
100130 #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text"))
@@ -108,7 +138,7 @@ struct FoldingTests {
108138 @Test func copyingAcrossAFoldIncludesHiddenText() {
109139 let h = Harness(outline)
110140 h.caret(at: 1)
111 h.editor.toggleFoldAtCaret()
141 h.editor.cycleAtCaret()
112142 h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text")))
113143 let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)"))
114144 defer { pasteboard.releaseGlobally() }
@@ -119,7 +149,7 @@ struct FoldingTests {
119149 @Test func foldsFollowEdits() {
120150 let h = Harness(outline)
121151 h.caret(at: h.offset(of: "* c") + 1)
122 h.editor.toggleFoldAtCaret()
152 h.editor.cycleAtCaret()
123153 h.caret(at: 0)
124154 h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0))
125155 #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"])
@@ -177,7 +207,7 @@ struct EditingTests {
177207 @Test func accessibilityReadsTheWholeText() {
178208 let h = Harness(outline)
179209 h.caret(at: 1)
180 h.editor.toggleFoldAtCaret()
210 h.editor.cycleAtCaret()
181211 #expect(h.textView.accessibilityValue() as? String == outline)
182212 h.textView.setSelectedRange(NSRange(location: 0, length: 3))
183213 #expect(h.textView.accessibilitySelectedText() == "* a")
@@ -280,3 +310,13 @@ struct InitialStylingTests {
280310 #expect(h.editor.styledUpTo == before + 3)
281311 }
282312}
313
314struct HangingIndentTests {
315 @Test(arguments: [
316 ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4),
317 ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
318 ])
319 func columns(line: String, expected: Int) {
320 #expect(OrgEditor.hangingColumns(line) == expected)
321 }
322}
Tests/OrgPresentationTests/PresentationTests.swift +44
@@ -76,3 +76,47 @@ struct FoldTests {
7676 #expect(Presentation.headings(tree) == [0, 9, 19])
7777 }
7878}
79
80struct CyclingTests {
81 let text = "* a\nbody\n** b\nmore\n*** c\ndeep\n* d\n"
82 var tree: OrgTree { OrgParser.parse(text) }
83
84 @Test func outline() {
85 let entries = Presentation.outline(tree)
86 #expect(entries.map(\.title) == ["a", "b", "c", "d"])
87 #expect(entries.map(\.parent) == [nil, 0, 1, nil])
88 #expect(entries.map(\.isTopLevel) == [true, false, false, true])
89 }
90
91 @Test func localCycle() {
92 var state = FoldState()
93 state = Presentation.cycle(0, in: tree, state)
94 #expect(state == FoldState(folds: [0]))
95 state = Presentation.cycle(0, in: tree, state)
96 #expect(state == FoldState(folds: [9]))
97 state = Presentation.cycle(0, in: tree, state)
98 #expect(state == FoldState())
99 #expect(Presentation.cycle(30, in: tree, FoldState(folds: [30])) == FoldState())
100 }
101
102 @Test func globalStates() {
103 #expect(Presentation.foldState(for: .overview, in: tree) == FoldState(folds: [0, 30]))
104 #expect(Presentation.foldState(for: .contents, in: tree) == FoldState(bodyFolds: [0, 9, 19, 30]))
105 #expect(Presentation.cycleGlobal(from: nil, in: tree).0 == .overview)
106 #expect(Presentation.cycleGlobal(from: .overview, in: tree).0 == .contents)
107 #expect(Presentation.cycleGlobal(from: .contents, in: tree).0 == .showAll)
108 }
109
110 @Test func bodyFoldsKeepChildHeadings() {
111 #expect(Presentation.hiddenRanges(tree, folds: [], bodyFolds: [0, 9, 19, 30]) == [4..<9, 14..<19, 25..<30])
112 }
113
114 @Test func startup() {
115 #expect(Presentation.startupFoldState(OrgParser.parse("#+STARTUP: content\n" + text)) != nil)
116 #expect(Presentation.startupFoldState(OrgParser.parse(text)) == nil)
117 let levels = OrgParser.parse("#+startup: show2levels\n" + text)
118 let state = Presentation.startupFoldState(levels)
119 #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2)
120 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
121 }
122}