Skip to content

Commit ea47016

Browse files
Async and Faster Folding Calculation (#331)
### Description Updates line folding to happen asynchronously off the main thread, and to work while editing text. It now remembers folded ranges and correctly handles nested folds. > Sorry for this huge commit log, it's terrible! Thankfully it'll be squashed when merged. The meat of the changes here are in `LineFoldCalculator`, `LineFoldModel`, and `LineFoldStorage`. I've moved some files, resulting in the large diff and I'm sorry for that for reviewers I know that makes it hard. - Refactors the folding model to use a new `LineFoldCalculator` type. - This type accepts an async stream of edit notifications, and produces a stream of the new `LineFoldStorage` type. - Asynchronously accesses text on the main thread for safety. - Adds a new `LineFoldStorage` type. - Internally uses the `RangeStore` type to quickly store fold ranges as spans in a text document. - Has methods for querying text ranges, collapsing ranges, and updating using new values from the `LineFoldCalculator` stream. - Is `Sendable` to work easily with async streams. - Updates the drawing code to handle new behaviors of the fold model. ### Related Issues * #43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/bc1d5bd1-bf87-45ba-ad0f-53655b2542fc --------- Co-authored-by: Austin Condiff <[email protected]>
1 parent f64ca21 commit ea47016

36 files changed

+1215
-573
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.resolved

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ let package = Package(
1616
dependencies: [
1717
// A fast, efficient, text view for code.
1818
.package(
19-
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20-
from: "0.11.1"
19+
path: "../CodeEditTextView"
20+
// url: "https://github.com/CodeEditApp/CodeEditTextView.git",
21+
// from: "0.11.1"
2122
),
2223
// tree-sitter languages
2324
.package(

Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 2 additions & 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()
@@ -143,6 +143,7 @@ extension TextViewController {
143143
- (self?.scrollView.contentInsets.top ?? 0)
144144

145145
self?.gutterView.needsDisplay = true
146+
self?.gutterView.foldingRibbon.needsDisplay = true
146147
self?.guideView?.updatePosition(in: textView)
147148
self?.scrollView.needsLayout = true
148149
}

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 waitMainIfNot<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.asyncAndWait(execute: item)
3735
}
3836
}
3937
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// NSBezierPath+RoundedCorners.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/3/25.
6+
//
7+
8+
import AppKit
9+
10+
// Wonderful NSBezierPath extension taken with modification from the playground code at:
11+
// https://github.com/janheiermann/BezierPath-Corners
12+
13+
extension NSBezierPath {
14+
struct Corners: OptionSet {
15+
public let rawValue: Int
16+
17+
public init(rawValue: Corners.RawValue) {
18+
self.rawValue = rawValue
19+
}
20+
21+
public static let topLeft = Corners(rawValue: 1 << 0)
22+
public static let bottomLeft = Corners(rawValue: 1 << 1)
23+
public static let topRight = Corners(rawValue: 1 << 2)
24+
public static let bottomRight = Corners(rawValue: 1 << 3)
25+
}
26+
27+
// swiftlint:disable:next function_body_length
28+
convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) {
29+
self.init()
30+
31+
let maxX = rect.maxX
32+
let minX = rect.minX
33+
let maxY = rect.maxY
34+
let minY = rect.minY
35+
let radius = min(cornerRadius, min(rect.width, rect.height) / 2)
36+
37+
// Start at bottom-left corner
38+
move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY))
39+
40+
// Bottom edge
41+
if corners.contains(.bottomRight) {
42+
line(to: CGPoint(x: maxX - radius, y: minY))
43+
appendArc(
44+
withCenter: CGPoint(x: maxX - radius, y: minY + radius),
45+
radius: radius,
46+
startAngle: 270,
47+
endAngle: 0,
48+
clockwise: false
49+
)
50+
} else {
51+
line(to: CGPoint(x: maxX, y: minY))
52+
}
53+
54+
// Right edge
55+
if corners.contains(.topRight) {
56+
line(to: CGPoint(x: maxX, y: maxY - radius))
57+
appendArc(
58+
withCenter: CGPoint(x: maxX - radius, y: maxY - radius),
59+
radius: radius,
60+
startAngle: 0,
61+
endAngle: 90,
62+
clockwise: false
63+
)
64+
} else {
65+
line(to: CGPoint(x: maxX, y: maxY))
66+
}
67+
68+
// Top edge
69+
if corners.contains(.topLeft) {
70+
line(to: CGPoint(x: minX + radius, y: maxY))
71+
appendArc(
72+
withCenter: CGPoint(x: minX + radius, y: maxY - radius),
73+
radius: radius,
74+
startAngle: 90,
75+
endAngle: 180,
76+
clockwise: false
77+
)
78+
} else {
79+
line(to: CGPoint(x: minX, y: maxY))
80+
}
81+
82+
// Left edge
83+
if corners.contains(.bottomLeft) {
84+
line(to: CGPoint(x: minX, y: minY + radius))
85+
appendArc(
86+
withCenter: CGPoint(x: minX + radius, y: minY + radius),
87+
radius: radius,
88+
startAngle: 180,
89+
endAngle: 270,
90+
clockwise: false
91+
)
92+
} else {
93+
line(to: CGPoint(x: minX, y: minY))
94+
}
95+
96+
close()
97+
}
98+
99+
convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) {
100+
switch (capTop, capBottom) {
101+
case (true, true):
102+
self.init(rect: roundingRect)
103+
case (false, true):
104+
self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius)
105+
case (true, false):
106+
self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius)
107+
case (false, false):
108+
self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius)
109+
}
110+
}
111+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// NSString+TextStory.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/3/25.
6+
//
7+
8+
import AppKit
9+
import TextStory
10+
11+
extension NSString: @retroactive TextStoring {
12+
public func substring(from range: NSRange) -> String? {
13+
self.substring(with: range)
14+
}
15+
16+
public func applyMutation(_ mutation: TextMutation) {
17+
self.replacingCharacters(in: mutation.range, with: mutation.string)
18+
}
19+
}

Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ extension TextView {
3030
let range = NSRange(location..<end)
3131
return self?.textStorage.substring(from: range)?.data(using: String.nativeUTF16Encoding)
3232
}
33-
return DispatchQueue.syncMainIfNot(workItem)
33+
return DispatchQueue.waitMainIfNot(workItem)
3434
}
3535
}
3636
/// Creates a block for safely reading data for a text provider.
@@ -45,7 +45,7 @@ extension TextView {
4545
let workItem: () -> String? = {
4646
self?.textStorage.substring(from: range)
4747
}
48-
return DispatchQueue.syncMainIfNot(workItem)
48+
return DispatchQueue.waitMainIfNot(workItem)
4949
}
5050
}
5151
}

Sources/CodeEditSourceEditor/Gutter/GutterView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public class GutterView: NSView {
101101
}
102102

103103
/// The view that draws the fold decoration in the gutter.
104-
private var foldingRibbon: FoldingRibbonView
104+
var foldingRibbon: FoldingRibbonView
105105

106106
/// Syntax helper for determining the required space for the folding ribbon.
107107
private var foldingRibbonWidth: CGFloat {
@@ -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/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift

Lines changed: 0 additions & 34 deletions
This file was deleted.

Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)