Skip to content

Commit 3b13a30

Browse files
committed
Move to Swift Concurrency with Async Streams
1 parent 0c6fe3d commit 3b13a30

10 files changed

Lines changed: 260 additions & 172 deletions

File tree

Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ extension TextViewController {
2626
font: font.rulerFont,
2727
textColor: theme.text.color.withAlphaComponent(0.35),
2828
selectedTextColor: theme.text.color,
29-
textView: textView,
29+
controller: self,
3030
delegate: self
3131
)
3232
gutterView.updateWidthIfNeeded()

Sources/CodeEditSourceEditor/Gutter/GutterView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,16 @@ public class GutterView: NSView {
137137
font: NSFont,
138138
textColor: NSColor,
139139
selectedTextColor: NSColor?,
140-
textView: TextView,
140+
controller: TextViewController,
141141
delegate: GutterViewDelegate? = nil
142142
) {
143143
self.font = font
144144
self.textColor = textColor
145145
self.selectedLineTextColor = selectedTextColor ?? .secondaryLabelColor
146-
self.textView = textView
146+
self.textView = controller.textView
147147
self.delegate = delegate
148148

149-
foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil)
149+
foldingRibbon = FoldingRibbonView(controller: controller, foldProvider: nil)
150150

151151
super.init(frame: .zero)
152152
clipsToBounds = true

Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,19 @@
66
//
77

88
import AppKit
9+
import TextStory
910
import CodeEditTextView
1011

12+
extension NSString: @retroactive TextStoring {
13+
public func substring(from range: NSRange) -> String? {
14+
self.substring(with: range)
15+
}
16+
17+
public func applyMutation(_ mutation: TextMutation) {
18+
self.replacingCharacters(in: mutation.range, with: mutation.string)
19+
}
20+
}
21+
1122
final class IndentationLineFoldProvider: LineFoldProvider {
1223
func indentLevelAtLine(substring: NSString) -> Int? {
1324
for idx in 0..<substring.length {
@@ -23,18 +34,24 @@ final class IndentationLineFoldProvider: LineFoldProvider {
2334
lineNumber: Int,
2435
lineRange: NSRange,
2536
previousDepth: Int,
26-
text: NSTextStorage
37+
controller: TextViewController
2738
) -> [LineFoldProviderLineInfo] {
39+
let text = controller.textView.textStorage.string as NSString
2840
guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines)?.length,
2941
leadingIndent != lineRange.length else {
3042
return []
3143
}
32-
3344
var foldIndicators: [LineFoldProviderLineInfo] = []
3445

35-
if leadingIndent < previousDepth {
46+
let leadingDepth = leadingIndent / controller.indentOption.charCount
47+
if leadingDepth < previousDepth {
3648
// End the fold at the start of whitespace
37-
foldIndicators.append(.endFold(rangeEnd: lineRange.location + leadingIndent, newDepth: leadingIndent))
49+
foldIndicators.append(
50+
.endFold(
51+
rangeEnd: lineRange.location + leadingIndent,
52+
newDepth: leadingDepth
53+
)
54+
)
3855
}
3956

4057
// Check if the next line has more indent
@@ -45,7 +62,12 @@ final class IndentationLineFoldProvider: LineFoldProvider {
4562
}
4663

4764
if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) {
48-
foldIndicators.append(.startFold(rangeStart: trailingWhitespace.location, newDepth: nextIndent))
65+
foldIndicators.append(
66+
.startFold(
67+
rangeStart: trailingWhitespace.location,
68+
newDepth: nextIndent / controller.indentOption.charCount
69+
)
70+
)
4971
}
5072

5173
return foldIndicators

Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ protocol LineFoldProvider: AnyObject {
3636
lineNumber: Int,
3737
lineRange: NSRange,
3838
previousDepth: Int,
39-
text: NSTextStorage
39+
controller: TextViewController
4040
) -> [LineFoldProviderLineInfo]
4141
}

Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift

Lines changed: 104 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -14,132 +14,140 @@ import Combine
1414
/// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously.
1515
/// Fold information is emitted via `rangesPublisher`.
1616
/// Notify the calculator it should re-calculate
17-
class LineFoldCalculator {
17+
actor LineFoldCalculator {
1818
weak var foldProvider: LineFoldProvider?
19-
weak var textView: TextView?
19+
weak var controller: TextViewController?
2020

21-
var rangesPublisher = CurrentValueSubject<LineFoldStorage, Never>(.init(documentLength: 0))
21+
var valueStream: AsyncStream<LineFoldStorage>
2222

23-
private let workQueue = DispatchQueue.global(qos: .default)
23+
private var valueStreamContinuation: AsyncStream<LineFoldStorage>.Continuation
24+
private var textChangedTask: Task<Void, Never>?
2425

25-
var textChangedReceiver = PassthroughSubject<(NSRange, Int), Never>()
26-
private var textChangedCancellable: AnyCancellable?
27-
28-
init(foldProvider: LineFoldProvider?, textView: TextView) {
26+
init(
27+
foldProvider: LineFoldProvider?,
28+
controller: TextViewController,
29+
textChangedStream: AsyncStream<(NSRange, Int)>
30+
) {
2931
self.foldProvider = foldProvider
30-
self.textView = textView
32+
self.controller = controller
33+
(valueStream, valueStreamContinuation) = AsyncStream<LineFoldStorage>.makeStream()
34+
Task { await listenToTextChanges(textChangedStream: textChangedStream) }
35+
}
36+
37+
deinit {
38+
textChangedTask?.cancel()
39+
}
3140

32-
textChangedCancellable = textChangedReceiver
33-
.throttle(for: 0.1, scheduler: RunLoop.main, latest: true)
34-
.sink { edit in
35-
self.buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1)
41+
private func listenToTextChanges(textChangedStream: AsyncStream<(NSRange, Int)>) {
42+
textChangedTask = Task {
43+
for await edit in textChangedStream {
44+
await buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1)
3645
}
46+
}
3747
}
3848

3949
/// Build out the folds for the entire document.
4050
///
4151
/// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the
4252
/// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in.
43-
private func buildFoldsForDocument(afterEditIn: NSRange, delta: Int) {
44-
workQueue.async {
45-
guard let textView = self.textView, let foldProvider = self.foldProvider else { return }
46-
var foldCache: [LineFoldStorage.RawFold] = []
47-
// Depth: Open range
48-
var openFolds: [Int: LineFoldStorage.RawFold] = [:]
49-
var currentDepth: Int = 0
50-
var iterator = textView.layoutManager.linesInRange(textView.documentRange)
53+
private func buildFoldsForDocument(afterEditIn: NSRange, delta: Int) async {
54+
guard let controller = self.controller, let foldProvider = self.foldProvider else { return }
55+
let documentRange = await controller.textView.documentRange
56+
var foldCache: [LineFoldStorage.RawFold] = []
57+
// Depth: Open range
58+
var openFolds: [Int: LineFoldStorage.RawFold] = [:]
59+
var currentDepth: Int = 0
60+
var iterator = await controller.textView.layoutManager.linesInRange(documentRange)
61+
62+
var lines = await self.getMoreLines(
63+
controller: controller,
64+
iterator: &iterator,
65+
previousDepth: currentDepth,
66+
foldProvider: foldProvider
67+
)
68+
while let lineChunk = lines {
69+
for lineInfo in lineChunk where lineInfo.depth > 0 {
70+
// Start a new fold, going deeper to a new depth.
71+
if lineInfo.depth > currentDepth {
72+
let newFold = LineFoldStorage.RawFold(
73+
depth: lineInfo.depth,
74+
range: lineInfo.rangeIndice..<lineInfo.rangeIndice
75+
)
76+
openFolds[newFold.depth] = newFold
77+
} else if lineInfo.depth < currentDepth {
78+
// End open folds > received depth
79+
for openFold in openFolds.values.filter({ $0.depth > lineInfo.depth }) {
80+
openFolds.removeValue(forKey: openFold.depth)
81+
foldCache.append(
82+
LineFoldStorage.RawFold(
83+
depth: openFold.depth,
84+
range: openFold.range.lowerBound..<lineInfo.rangeIndice
85+
)
86+
)
87+
}
88+
}
5189

52-
var lines = self.getMoreLines(
53-
textView: textView,
90+
currentDepth = lineInfo.depth
91+
}
92+
lines = await self.getMoreLines(
93+
controller: controller,
5494
iterator: &iterator,
5595
previousDepth: currentDepth,
5696
foldProvider: foldProvider
5797
)
58-
while let lineChunk = lines {
59-
for lineInfo in lineChunk where lineInfo.depth > 0 {
60-
// Start a new fold, going deeper to a new depth.
61-
if lineInfo.depth > currentDepth {
62-
let newFold = LineFoldStorage.RawFold(
63-
depth: lineInfo.depth,
64-
range: lineInfo.rangeIndice..<lineInfo.rangeIndice
65-
)
66-
openFolds[newFold.depth] = newFold
67-
} else if lineInfo.depth < currentDepth {
68-
// End open folds > received depth
69-
for openFold in openFolds.values.filter({ $0.depth > lineInfo.depth }) {
70-
openFolds.removeValue(forKey: openFold.depth)
71-
foldCache.append(
72-
LineFoldStorage.RawFold(
73-
depth: openFold.depth,
74-
range: openFold.range.lowerBound..<lineInfo.rangeIndice
75-
)
76-
)
77-
}
78-
}
98+
}
7999

80-
currentDepth = lineInfo.depth
81-
}
82-
lines = self.getMoreLines(
83-
textView: textView,
84-
iterator: &iterator,
85-
previousDepth: currentDepth,
86-
foldProvider: foldProvider
100+
// Clean up any hanging folds.
101+
for fold in openFolds.values {
102+
foldCache.append(
103+
LineFoldStorage.RawFold(
104+
depth: fold.depth,
105+
range: fold.range.lowerBound..<documentRange.length
87106
)
88-
}
107+
)
108+
}
89109

90-
// Clean up any hanging folds.
91-
for fold in openFolds.values {
92-
foldCache.append(
93-
LineFoldStorage.RawFold(
94-
depth: fold.depth,
95-
range: fold.range.lowerBound..<textView.length
96-
)
97-
)
110+
let attachments = await controller.textView.layoutManager.attachments
111+
.getAttachmentsOverlapping(documentRange)
112+
.compactMap { $0.attachment as? LineFoldPlaceholder }
113+
.map {
114+
LineFoldStorage.DepthStartPair(depth: $0.fold.depth, start: $0.fold.range.lowerBound)
98115
}
99116

100-
let storage = LineFoldStorage(
101-
documentLength: textView.length,
102-
folds: foldCache.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }),
103-
collapsedProvider: {
104-
Set(
105-
textView.layoutManager.attachments
106-
.getAttachmentsOverlapping(textView.documentRange)
107-
.compactMap { $0.attachment as? LineFoldPlaceholder }
108-
.map {
109-
LineFoldStorage.DepthStartPair(depth: $0.fold.depth, start: $0.fold.range.lowerBound)
110-
}
111-
)
112-
}
113-
)
114-
self.rangesPublisher.send(storage)
115-
}
117+
let storage = LineFoldStorage(
118+
documentLength: foldCache.max(
119+
by: { $0.range.upperBound < $1.range.upperBound }
120+
)?.range.upperBound ?? documentRange.length,
121+
folds: foldCache.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }),
122+
collapsedProvider: { Set(attachments) }
123+
)
124+
valueStreamContinuation.yield(storage)
116125
}
117126

127+
@MainActor
118128
private func getMoreLines(
119-
textView: TextView,
129+
controller: TextViewController,
120130
iterator: inout TextLayoutManager.RangeIterator,
121131
previousDepth: Int,
122132
foldProvider: LineFoldProvider
123133
) -> [LineFoldProviderLineInfo]? {
124-
DispatchQueue.main.asyncAndWait {
125-
var results: [LineFoldProviderLineInfo] = []
126-
var count = 0
127-
var previousDepth: Int = previousDepth
128-
while count < 50, let linePosition = iterator.next() {
129-
let foldInfo = foldProvider.foldLevelAtLine(
130-
lineNumber: linePosition.index,
131-
lineRange: linePosition.range,
132-
previousDepth: previousDepth,
133-
text: textView.textStorage
134-
)
135-
results.append(contentsOf: foldInfo)
136-
count += 1
137-
previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth
138-
}
139-
if results.isEmpty && count == 0 {
140-
return nil
141-
}
142-
return results
134+
var results: [LineFoldProviderLineInfo] = []
135+
var count = 0
136+
var previousDepth: Int = previousDepth
137+
while count < 50, let linePosition = iterator.next() {
138+
let foldInfo = foldProvider.foldLevelAtLine(
139+
lineNumber: linePosition.index,
140+
lineRange: linePosition.range,
141+
previousDepth: previousDepth,
142+
controller: controller
143+
)
144+
results.append(contentsOf: foldInfo)
145+
count += 1
146+
previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth
147+
}
148+
if results.isEmpty && count == 0 {
149+
return nil
143150
}
151+
return results
144152
}
145153
}

Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import _RopeModule
88
import Foundation
99

1010
/// Represents a single fold region with stable identifier and collapse state
11-
struct FoldRange: Sendable {
11+
struct FoldRange: Sendable, Equatable {
1212
typealias FoldIdentifier = UInt32
1313

1414
let id: FoldIdentifier
@@ -17,6 +17,14 @@ struct FoldRange: Sendable {
1717
var isCollapsed: Bool
1818
}
1919

20+
/// Represents a single fold run with stable identifier and collapse state
21+
struct FoldRun: Sendable {
22+
let id: FoldRange.FoldIdentifier
23+
let depth: Int
24+
let range: Range<Int>
25+
let isCollapsed: Bool
26+
}
27+
2028
/// Sendable data model for code folding using RangeStore
2129
struct LineFoldStorage: Sendable {
2230
/// A temporary fold representation without stable ID
@@ -112,6 +120,20 @@ struct LineFoldStorage: Sendable {
112120
}
113121

114122
return result.sorted { $0.range.lowerBound < $1.range.lowerBound }
123+
// let runs = store.runs(in: queryRange.clamped(to: 0..<store.length))
124+
// var currentLocation = queryRange.lowerBound
125+
// return runs.compactMap { run in
126+
// defer {
127+
// currentLocation += run.length
128+
// }
129+
// guard let value = run.value else { return nil }
130+
// return FoldRun(
131+
// id: value.id,
132+
// depth: value.depth,
133+
// range: currentLocation..<(currentLocation + run.length),
134+
// isCollapsed: foldRanges[value.id]?.isCollapsed ?? false
135+
// )
136+
// }
115137
}
116138

117139
/// Given a depth and a location, return the full original fold region

0 commit comments

Comments
 (0)