Skip to content

Commit 2c1af46

Browse files
committed
Dispatch Folding Calculation To Background
1 parent 4d9d1d0 commit 2c1af46

5 files changed

Lines changed: 126 additions & 99 deletions

File tree

Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,11 @@ extension DispatchQueue {
2727
/// executed if not already on the main thread.
2828
/// - Parameter item: The work item to execute.
2929
/// - Returns: The value of the work item.
30-
static func syncMainIfNot<T>(_ item: @escaping () -> T) -> T {
30+
static func syncMainIfNot<T>(_ item: () -> T) -> T {
3131
if Thread.isMainThread {
3232
return item()
3333
} else {
34-
return DispatchQueue.main.sync {
35-
return item()
36-
}
34+
return DispatchQueue.main.sync(execute: item)
3735
}
3836
}
3937
}

Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,13 @@ import AppKit
99
import CodeEditTextView
1010

1111
final class IndentationLineFoldProvider: LineFoldProvider {
12-
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? {
13-
guard let linePosition = layoutManager.textLineForIndex(lineNumber),
14-
let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else {
15-
return nil
12+
func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int? {
13+
for idx in 0..<substring.length {
14+
let character = UnicodeScalar(substring.character(at: idx))
15+
if character?.properties.isWhitespace == false {
16+
return idx
17+
}
1618
}
17-
18-
return indentLevel
19-
}
20-
21-
private func indentLevelForPosition(
22-
_ position: TextLineStorage<TextLine>.TextLinePosition,
23-
textStorage: NSTextStorage
24-
) -> Int? {
25-
guard let substring = textStorage.substring(from: position.range) else {
26-
return nil
27-
}
28-
29-
return substring.utf16 // Keep NSString units
30-
.enumerated()
31-
.first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })?
32-
.offset
19+
return nil
3320
}
3421
}

Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import CodeEditTextView
1111
import Combine
1212

1313
#warning("Replace before release")
14-
fileprivate let demoFoldProvider = IndentationLineFoldProvider()
14+
private let demoFoldProvider = IndentationLineFoldProvider()
1515

1616
/// Displays the code folding ribbon in the ``GutterView``.
1717
///
@@ -100,7 +100,7 @@ class FoldingRibbonView: NSView {
100100
layerContentsRedrawPolicy = .onSetNeedsDisplay
101101
clipsToBounds = false
102102

103-
foldUpdateCancellable = model.foldsUpdatedPublisher.sink {
103+
foldUpdateCancellable = model.$foldCache.receive(on: RunLoop.main).sink { _ in
104104
self.needsDisplay = true
105105
}
106106
}

Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ import AppKit
99
import CodeEditTextView
1010

1111
protocol LineFoldProvider: AnyObject {
12-
func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int?
12+
func foldLevelAtLine(_ lineNumber: Int, substring: NSString) -> Int?
1313
}

Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift

Lines changed: 114 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,99 @@ import AppKit
99
import CodeEditTextView
1010
import Combine
1111

12+
class LineFoldCalculator {
13+
weak var foldProvider: LineFoldProvider?
14+
weak var textView: TextView?
15+
16+
var rangesPublisher = CurrentValueSubject<[FoldRange], Never>([])
17+
18+
private let workQueue = DispatchQueue(label: "app.codeedit.line-folds")
19+
20+
var textChangedReceiver = PassthroughSubject<Void, Never>()
21+
private var textChangedCancellable: AnyCancellable?
22+
23+
init(foldProvider: LineFoldProvider?, textView: TextView) {
24+
self.foldProvider = foldProvider
25+
self.textView = textView
26+
27+
textChangedCancellable = textChangedReceiver.throttle(for: 0.1, scheduler: RunLoop.main, latest: true).sink {
28+
self.buildFoldsForDocument()
29+
}
30+
}
31+
32+
/// Build out the folds for the entire document.
33+
///
34+
/// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the
35+
/// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in.
36+
private func buildFoldsForDocument() {
37+
workQueue.async {
38+
guard let textView = self.textView, let foldProvider = self.foldProvider else { return }
39+
var foldCache: [FoldRange] = []
40+
var currentFold: FoldRange?
41+
var currentDepth: Int = 0
42+
var iterator = textView.layoutManager.linesInRange(textView.documentRange)
43+
44+
var lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider)
45+
while !lines.isEmpty {
46+
for (lineNumber, foldDepth) in lines {
47+
// Start a new fold
48+
if foldDepth > currentDepth {
49+
let newFold = FoldRange(
50+
lineRange: (lineNumber - 1)...(lineNumber - 1),
51+
range: .zero,
52+
parent: currentFold,
53+
subFolds: []
54+
)
55+
56+
if currentFold == nil {
57+
foldCache.append(newFold)
58+
} else {
59+
currentFold?.subFolds.append(newFold)
60+
}
61+
currentFold = newFold
62+
} else if foldDepth < currentDepth {
63+
// End this fold
64+
if let fold = currentFold {
65+
fold.lineRange = fold.lineRange.lowerBound...lineNumber
66+
}
67+
currentFold = currentFold?.parent
68+
}
69+
70+
currentDepth = foldDepth
71+
}
72+
lines = self.getMoreLines(textView: textView, iterator: &iterator, foldProvider: foldProvider)
73+
}
74+
75+
self.rangesPublisher.send(foldCache)
76+
}
77+
}
78+
79+
private func getMoreLines(
80+
textView: TextView,
81+
iterator: inout TextLayoutManager.RangeIterator,
82+
foldProvider: LineFoldProvider
83+
) -> [(index: Int, foldDepth: Int)] {
84+
DispatchQueue.main.asyncAndWait {
85+
var results: [(index: Int, foldDepth: Int)] = []
86+
var count = 0
87+
while count < 50, let linePosition = iterator.next() {
88+
guard let substring = textView.textStorage.substring(from: linePosition.range) as NSString?,
89+
let foldDepth = foldProvider.foldLevelAtLine(
90+
linePosition.index,
91+
substring: substring
92+
) else {
93+
count += 1
94+
continue
95+
}
96+
97+
results.append((linePosition.index, foldDepth))
98+
count += 1
99+
}
100+
return results
101+
}
102+
}
103+
}
104+
12105
/// # Basic Premise
13106
///
14107
/// We need to update, delete, or add fold ranges in the invalidated lines.
@@ -21,91 +114,35 @@ import Combine
21114
class LineFoldingModel: NSObject, NSTextStorageDelegate {
22115
/// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent``
23116
/// and ``FoldRange/subFolds``.
24-
private var foldCache: [FoldRange] = []
117+
@Published var foldCache: [FoldRange] = []
118+
private var calculator: LineFoldCalculator
119+
private var cancellable: AnyCancellable?
25120

26-
weak var foldProvider: LineFoldProvider?
27121
weak var textView: TextView?
28122

29-
lazy var foldsUpdatedPublisher = PassthroughSubject<Void, Never>()
30-
31123
init(textView: TextView, foldProvider: LineFoldProvider?) {
32124
self.textView = textView
33-
self.foldProvider = foldProvider
125+
self.calculator = LineFoldCalculator(foldProvider: foldProvider, textView: textView)
34126
super.init()
35127
textView.addStorageDelegate(self)
36-
buildFoldsForDocument()
128+
cancellable = self.calculator.rangesPublisher.receive(on: RunLoop.main).assign(to: \.foldCache, on: self)
129+
calculator.textChangedReceiver.send()
37130
}
38131

39132
func getFolds(in lineRange: ClosedRange<Int>) -> [FoldRange] {
40133
foldCache.filter({ $0.lineRange.overlaps(lineRange) })
41134
}
42135

43-
/// Build out the ``foldCache`` for the entire document.
44-
///
45-
/// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the
46-
/// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in.
47-
func buildFoldsForDocument() {
48-
guard let textView, let foldProvider else { return }
49-
foldCache.removeAll(keepingCapacity: true)
50-
51-
var currentFold: FoldRange?
52-
var currentDepth: Int = 0
53-
for linePosition in textView.layoutManager.linesInRange(textView.documentRange) {
54-
guard let foldDepth = foldProvider.foldLevelAtLine(
55-
linePosition.index,
56-
layoutManager: textView.layoutManager,
57-
textStorage: textView.textStorage
58-
) else {
59-
continue
60-
}
61-
62-
// Start a new fold
63-
if foldDepth > currentDepth {
64-
let newFold = FoldRange(
65-
lineRange: (linePosition.index - 1)...(linePosition.index - 1),
66-
range: .zero,
67-
parent: currentFold,
68-
subFolds: []
69-
)
70-
71-
if currentFold == nil {
72-
foldCache.append(newFold)
73-
} else {
74-
currentFold?.subFolds.append(newFold)
75-
}
76-
currentFold = newFold
77-
} else if foldDepth < currentDepth {
78-
// End this fold
79-
if let fold = currentFold {
80-
fold.lineRange = fold.lineRange.lowerBound...linePosition.index
81-
}
82-
currentFold = currentFold?.parent
83-
}
84-
85-
currentDepth = foldDepth
86-
}
87-
88-
foldsUpdatedPublisher.send()
89-
}
90-
91-
func invalidateLine(lineNumber: Int) {
92-
// TODO: Check if we need to rebuild, or even better, incrementally update the tree.
93-
94-
// Temporary
95-
buildFoldsForDocument()
96-
}
97-
98136
func textStorage(
99137
_ textStorage: NSTextStorage,
100138
didProcessEditing editedMask: NSTextStorageEditActions,
101139
range editedRange: NSRange,
102140
changeInLength delta: Int
103141
) {
104-
guard editedMask.contains(.editedCharacters),
105-
let lineNumber = textView?.layoutManager.textLineForOffset(editedRange.location)?.index else {
142+
guard editedMask.contains(.editedCharacters) else {
106143
return
107144
}
108-
invalidateLine(lineNumber: lineNumber)
145+
calculator.textChangedReceiver.send()
109146
}
110147

111148
/// Finds the deepest cached depth of the fold for a line number.
@@ -119,20 +156,20 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate {
119156
/// - Parameter lineNumber: The line number to query, zero-indexed.
120157
/// - Returns: The deepest cached fold and depth of the fold if it was found.
121158
func getCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? {
122-
binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0)
159+
binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0, findDeepest: true)
123160
}
124161
}
125162

126163
// MARK: - Search Folds
127164

128165
private extension LineFoldingModel {
129-
130166
/// A generic function for searching an ordered array of fold ranges.
131167
/// - Returns: The found range and depth it was found at, if it exists.
132168
func binarySearchFoldsArray(
133169
lineNumber: Int,
134170
folds: borrowing [FoldRange],
135-
currentDepth: Int
171+
currentDepth: Int,
172+
findDeepest: Bool
136173
) -> (range: FoldRange, depth: Int)? {
137174
var low = 0
138175
var high = folds.count - 1
@@ -143,11 +180,16 @@ private extension LineFoldingModel {
143180

144181
if fold.lineRange.contains(lineNumber) {
145182
// Search deeper into subFolds, if any
146-
return binarySearchFoldsArray(
147-
lineNumber: lineNumber,
148-
folds: fold.subFolds,
149-
currentDepth: currentDepth + 1
150-
) ?? (fold, currentDepth)
183+
if findDeepest {
184+
return binarySearchFoldsArray(
185+
lineNumber: lineNumber,
186+
folds: fold.subFolds,
187+
currentDepth: currentDepth + 1,
188+
findDeepest: findDeepest
189+
) ?? (fold, currentDepth)
190+
} else {
191+
return (fold, currentDepth)
192+
}
151193
} else if lineNumber < fold.lineRange.lowerBound {
152194
high = mid - 1
153195
} else {

0 commit comments

Comments
 (0)