Sources/OrgPresentation/Folding.swift
155 lines · 6391 bytes
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}