Sources/OrgPresentation/Folding.swift
261 lines · 12122 bytes
1import OrgCore
2
3/// Which headings are folded, whole subtrees or only their own bodies, and which drawers and
4/// blocks. Drawer and block folds are kept apart, as org's `drawer` and `block` fold specs:
5/// cycling headings leaves them as they are.
6public struct FoldState: Sendable, Equatable {
7 public var folds: Set<Int>
8 public var bodyFolds: Set<Int>
9 /// Opening-line starts of folded drawers and blocks.
10 public var wrapperFolds: Set<Int>
11
12 public init(folds: Set<Int> = [], bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) {
13 self.folds = folds
14 self.bodyFolds = bodyFolds
15 self.wrapperFolds = wrapperFolds
16 }
17}
18
19/// org's global visibility states, cycled by Shift-TAB.
20public enum GlobalVisibility: Sendable, Equatable {
21 /// Top-level headings only.
22 case overview
23 /// Every heading, no bodies.
24 case contents
25 case showAll
26}
27
28public struct OutlineEntry: Sendable, Equatable {
29 /// Start offset of the heading line.
30 public let start: Int
31 public let level: Int
32 /// The title as written.
33 public let title: String
34 /// End of the heading's section.
35 public let end: Int
36 /// Index of the parent entry.
37 public let parent: Int?
38 public let isTopLevel: Bool
39}
40
41extension Presentation {
42 /// Every heading in document order.
43 public static func outline(_ tree: OrgTree) -> [OutlineEntry] {
44 var entries: [OutlineEntry] = []
45 collectOutline(tree.root, parent: nil, topLevel: true, &entries)
46 return entries
47 }
48
49 private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) {
50 for section in node.children where section.kind == .section {
51 guard let heading = section.firstChild(.heading) else { continue }
52 let index = entries.count
53 entries.append(OutlineEntry(
54 start: heading.range.lowerBound,
55 level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1,
56 title: heading.firstChild(.title)?.text ?? "",
57 end: section.range.upperBound,
58 parent: parent,
59 isTopLevel: topLevel
60 ))
61 collectOutline(section, parent: index, topLevel: false, &entries)
62 }
63 }
64
65 /// TAB on a heading: folded, then its children, then the whole subtree, then folded again.
66 /// A heading without children goes straight from folded to unfolded.
67 public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
68 let outline = outline(tree)
69 guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
70 let entry = outline[index]
71 let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start)
72 let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start)
73 var next = state
74 func unfoldSubtree() {
75 next.folds.subtract([heading] + descendants)
76 next.bodyFolds.subtract([heading] + descendants)
77 }
78 if state.folds.contains(heading) {
79 unfoldSubtree()
80 next.folds.formUnion(children)
81 } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) {
82 unfoldSubtree()
83 } else {
84 unfoldSubtree()
85 next.folds.insert(heading)
86 }
87 return next
88 }
89
90 /// Doom's `za` (`+org/toggle-fold`) on a heading: folded, it opens one level (its body and
91 /// child headings, folded); otherwise it folds.
92 public static func toggleFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
93 isFolded(heading, state) ? showChildren(heading, in: tree, state) : hideSubtree(heading, state)
94 }
95
96 /// Doom's `zo` (`+org/open-fold`): a folded heading opens one level; an open one stays.
97 public static func openFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
98 isFolded(heading, state) ? showChildren(heading, in: tree, state) : state
99 }
100
101 /// `zc` (`outline-hide-subtree`): the heading's subtree folded.
102 public static func hideSubtree(_ heading: Int, _ state: FoldState) -> FoldState {
103 var next = state
104 next.folds.insert(heading)
105 return next
106 }
107
108 /// Whether the heading's body is hidden.
109 private static func isFolded(_ heading: Int, _ state: FoldState) -> Bool {
110 state.folds.contains(heading) || state.bodyFolds.contains(heading)
111 }
112
113 /// org's children view of a heading: its body and child headings show, their subtrees folded.
114 private static func showChildren(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
115 let outline = outline(tree)
116 guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
117 let entry = outline[index]
118 let subtree = outline.filter { $0.start >= entry.start && $0.start < entry.end }.map(\.start)
119 var next = state
120 next.folds.subtract(subtree)
121 next.bodyFolds.subtract(subtree)
122 next.folds.formUnion(outline.enumerated().filter { $0.element.parent == index }.map(\.element.start))
123 return next
124 }
125
126 /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or
127 /// unfolds it; on a heading, cycles it. Nil elsewhere.
128 public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? {
129 if let wrapper = Wrappers.toggleable(at: offset, in: tree) {
130 var next = state
131 if next.wrapperFolds.remove(wrapper.start) == nil { next.wrapperFolds.insert(wrapper.start) }
132 return next
133 }
134 return heading(containing: offset, in: tree).map { cycle($0, in: tree, state) }
135 }
136
137 /// Shift-TAB: overview, contents, show all, and around. Drawer and block folds carry over
138 /// from `wrapperFolds`: show all opens blocks, and overview and contents fold the drawers
139 /// before the first heading (`org-cycle-hide-drawers`).
140 public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree, wrapperFolds: Set<Int> = []) -> (GlobalVisibility, FoldState) {
141 let next: GlobalVisibility = switch current {
142 case .overview: .contents
143 case .contents: .showAll
144 case .showAll, nil: .overview
145 }
146 var state = foldState(for: next, in: tree)
147 let wrappers = Wrappers.all(tree)
148 switch next {
149 case .showAll:
150 let blocks = Set(wrappers.filter { $0.kind == .block }.map(\.start))
151 state.wrapperFolds = wrapperFolds.subtracting(blocks)
152 case .overview, .contents:
153 let firstHeading = outline(tree).first?.start ?? Int.max
154 state.wrapperFolds = wrapperFolds.union(wrappers.filter { $0.kind == .drawer && $0.start < firstHeading }.map(\.start))
155 }
156 return (next, state)
157 }
158
159 /// The drawers and blocks folded when a file opens: drawers unless `org-cycle-hide-drawer-startup`
160 /// is off, blocks when `org-cycle-hide-block-startup` is on; `#+STARTUP` (`hidedrawers`,
161 /// `nohidedrawers`, `hideblocks`, `nohideblocks`, `showeverything`) overrides both.
162 public static func startupWrapperFolds(_ tree: OrgTree, hideDrawers: Bool = true, hideBlocks: Bool = false) -> Set<Int> {
163 Set(startupWrappers(tree, hideDrawers: hideDrawers, hideBlocks: hideBlocks).map(\.start))
164 }
165
166 public static func startupWrappers(_ tree: OrgTree, hideDrawers: Bool = true, hideBlocks: Bool = false) -> [Wrapper] {
167 var drawers = hideDrawers
168 var blocks = hideBlocks
169 for option in startupOptions(tree) {
170 switch option.lowercased() {
171 case "hidedrawers": drawers = true
172 case "nohidedrawers": drawers = false
173 case "hideblocks": blocks = true
174 case "nohideblocks": blocks = false
175 case "showeverything": return []
176 default: break
177 }
178 }
179 guard drawers || blocks else { return [] }
180 return Wrappers.all(tree).filter { $0.kind == .drawer ? drawers : blocks }
181 }
182
183 public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState {
184 let outline = outline(tree)
185 switch visibility {
186 case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start)))
187 case .contents: return FoldState(bodyFolds: Set(outline.map(\.start)))
188 case .showAll: return FoldState()
189 }
190 }
191
192 /// `org-cycle-set-visibility-according-to-property` and `org-cycle-hide-archived-subtrees`
193 /// after the startup visibility: each heading's `VISIBILITY` (`folded`, `children`, `content`,
194 /// `all`) in turn, then ARCHIVE-tagged subtrees folded.
195 public static func applyVisibilityProperties(_ state: FoldState, in tree: OrgTree) -> FoldState {
196 let outline = outline(tree)
197 let model = DocumentModel(tree: tree)
198 var next = state
199 for (index, heading) in model.headings.enumerated() {
200 guard index < outline.count,
201 let value = heading.properties.last(where: { $0.key.uppercased() == "VISIBILITY" })?.value.lowercased() else { continue }
202 let entry = outline[index]
203 let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start)
204 let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start)
205 switch value {
206 case "folded":
207 next.folds.insert(entry.start)
208 case "children":
209 next.folds.remove(entry.start)
210 next.bodyFolds.remove(entry.start)
211 next.folds.formUnion(children)
212 case "content":
213 next.folds.subtract([entry.start] + descendants)
214 next.bodyFolds.formUnion([entry.start] + descendants)
215 case "all", "showall":
216 next.folds.subtract([entry.start] + descendants)
217 next.bodyFolds.subtract([entry.start] + descendants)
218 default:
219 break
220 }
221 }
222 for (index, heading) in model.headings.enumerated() where index < outline.count && heading.localTags.contains("ARCHIVE") {
223 next.folds.insert(outline[index].start)
224 }
225 return next
226 }
227
228 /// Headings of levels 1 through `levels` visible, no bodies.
229 public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState {
230 let outline = outline(tree)
231 return FoldState(
232 folds: Set(outline.filter { $0.level == levels }.map(\.start)),
233 bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start))
234 )
235 }
236
237 /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none.
238 /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`...
239 public static func startupFoldState(_ tree: OrgTree) -> FoldState? {
240 var result: FoldState?
241 for option in startupOptions(tree) {
242 switch option.lowercased() {
243 case "overview", "fold": result = foldState(for: .overview, in: tree)
244 case "content": result = foldState(for: .contents, in: tree)
245 case "showall", "nofold", "showeverything": result = FoldState()
246 case let word where word.hasPrefix("show") && word.hasSuffix("levels"):
247 if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 {
248 result = foldState(showingLevels: levels, in: tree)
249 }
250 default: break
251 }
252 }
253 return result
254 }
255
256 /// Words of every `#+STARTUP:` keyword line (`OrgSettings.startup`); lines inside blocks
257 /// are not settings.
258 public static func startupOptions(_ tree: OrgTree) -> [String] {
259 tree.settings.startup
260 }
261}