krz/orgstar

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

Sources/OrgPresentation/Folding.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgPresentation/Folding.swift history · blame · raw

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}