Sources/OrgDocument/ViewState.swift
60 lines · 2579 bytes
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
11 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
12 self.selection = selection
13 self.folds = folds
14 self.bodyFolds = bodyFolds
15 }
16
17 /// Maps through non-overlapping edits given in old coordinates.
18 public func mapped(through edits: [TextEdit]) -> ViewState {
19 let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound }
20 return ViewState(
21 selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
22 folds: Set(folds.map { mapOffset($0, sorted) }),
23 bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
24 )
25 }
26
27 /// Drops folds that no longer sit at the start of a heading.
28 public func pruned(to tree: OrgTree) -> ViewState {
29 guard !folds.isEmpty || !bodyFolds.isEmpty else { return self }
30 return ViewState(
31 selection: selection,
32 folds: folds.filter { isHeadingStart($0, tree.root) },
33 bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) }
34 )
35 }
36}
37
38/// Descends only through the sections containing `offset`.
39func isHeadingStart(_ offset: Int, _ root: SyntaxNode) -> Bool {
40 var node = root
41 while let child = node.child(containing: offset) {
42 if child.kind == .heading { return child.range.lowerBound == offset }
43 guard child.kind == .section || child.kind == .zerothSection else { return false }
44 node = child
45 }
46 return false
47}
48
49/// An offset before an edit stays put; one at the start of a replaced range stays at its start;
50/// one inside it, at its end, or at an insertion point moves past the replacement.
51func mapOffset(_ offset: Int, _ sortedEdits: [TextEdit]) -> Int {
52 var shift = 0
53 for edit in sortedEdits {
54 let lower = edit.range.lowerBound, upper = edit.range.upperBound
55 if offset < lower || (offset == lower && upper > lower) { return offset + shift }
56 if offset <= upper { return lower + shift + edit.replacement.utf16.count }
57 shift += edit.replacement.utf16.count - edit.range.count
58 }
59 return offset + shift
60}