krz/orgstar

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

Sources/OrgPresentation/Folding.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgPresentation/Folding.swift history · blame · raw

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}