krz/orgstar

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

Sources/OrgDocument/ViewState.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgDocument/ViewState.swift history · blame · raw

66 lines · 3104 bytes

5 symbols in this file
 1import OrgCore
 2
 3/// Per-window state for one document: selection and folds, in UTF-16 offsets.
 4public struct ViewState: Sendable, Equatable {
 5    public var selection: [Range<Int>]
 6    /// Start offsets of headings whose whole subtree is folded.
 7    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>
10    /// Opening-line starts of folded drawers and blocks.
11    public var wrapperFolds: Set<Int>
12
13    public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) {
14        self.selection = selection
15        self.folds = folds
16        self.bodyFolds = bodyFolds
17        self.wrapperFolds = wrapperFolds
18    }
19
20    /// Maps through non-overlapping edits given in old coordinates.
21    public func mapped(through edits: [TextEdit]) -> ViewState {
22        let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
23        return ViewState(
24            selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
25            folds: Set(folds.map { mapOffset($0, sorted) }),
26            bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) }),
27            wrapperFolds: Set(wrapperFolds.map { mapOffset($0, sorted) })
28        )
29    }
30
31    /// Drops folds that no longer sit at the start of a heading, drawer or block.
32    /// Without `wrappers`, drawer and block folds are left for the caller to check.
33    public func pruned(to tree: OrgTree, wrappers: Bool = true) -> ViewState {
34        guard !folds.isEmpty || !bodyFolds.isEmpty || (wrappers && !wrapperFolds.isEmpty) else { return self }
35        return ViewState(
36            selection: selection,
37            folds: folds.filter { isHeadingStart($0, tree.root) },
38            bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) },
39            wrapperFolds: wrappers ? wrapperFolds.filter { Wrappers.starting(at: $0, in: tree) != nil } : wrapperFolds
40        )
41    }
42}
43
44/// Descends only through the sections containing `offset`.
45func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
46    var node = root
47    while let child = node.child(containing: offset) {
48        if child.kind == .heading { return child.range.lowerBound == offset }
49        guard child.kind == .section || child.kind == .zerothSection else { return false }
50        node = child
51    }
52    return false
53}
54
55/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
56/// one inside it, at its end, or at an insertion point moves past the replacement.
57func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
58    var shift = 0
59    for edit in sortedEdits {
60        let lower = edit.range.lowerBound, upper = edit.range.upperBound
61        if offset < lower || (offset == lower && upper > lower) { return offset + shift }
62        if offset <= upper { return lower + shift + edit.replacement.utf16.count }
63        shift += edit.replacement.utf16.count - edit.range.count
64    }
65    return offset + shift
66}