Skip to content

Commit f51ed8e

Browse files
Feat: Highlighter Provider Diffing (#291)
1 parent 17525ad commit f51ed8e

File tree

10 files changed

+271
-52
lines changed

10 files changed

+271
-52
lines changed

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

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

Package.resolved

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

Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
5757
editorOverscroll: CGFloat = 0,
5858
cursorPositions: Binding<[CursorPosition]>,
5959
useThemeBackground: Bool = true,
60-
highlightProviders: [HighlightProviding] = [TreeSitterClient()],
60+
highlightProviders: [any HighlightProviding] = [TreeSitterClient()],
6161
contentInsets: NSEdgeInsets? = nil,
6262
isEditable: Bool = true,
6363
isSelectable: Bool = true,
@@ -132,7 +132,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
132132
editorOverscroll: CGFloat = 0,
133133
cursorPositions: Binding<[CursorPosition]>,
134134
useThemeBackground: Bool = true,
135-
highlightProviders: [HighlightProviding] = [TreeSitterClient()],
135+
highlightProviders: [any HighlightProviding] = [TreeSitterClient()],
136136
contentInsets: NSEdgeInsets? = nil,
137137
isEditable: Bool = true,
138138
isSelectable: Bool = true,
@@ -179,7 +179,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
179179
private var editorOverscroll: CGFloat
180180
package var cursorPositions: Binding<[CursorPosition]>
181181
private var useThemeBackground: Bool
182-
private var highlightProviders: [HighlightProviding]
182+
private var highlightProviders: [any HighlightProviding]
183183
private var contentInsets: NSEdgeInsets?
184184
private var isEditable: Bool
185185
private var isSelectable: Bool
@@ -305,6 +305,10 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
305305
controller.useSystemCursor = useSystemCursor
306306
}
307307

308+
if !areHighlightProvidersEqual(controller: controller) {
309+
controller.setHighlightProviders(highlightProviders)
310+
}
311+
308312
controller.bracketPairHighlight = bracketPairHighlight
309313
}
310314

@@ -326,7 +330,12 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
326330
controller.tabWidth == tabWidth &&
327331
controller.letterSpacing == letterSpacing &&
328332
controller.bracketPairHighlight == bracketPairHighlight &&
329-
controller.useSystemCursor == useSystemCursor
333+
controller.useSystemCursor == useSystemCursor &&
334+
areHighlightProvidersEqual(controller: controller)
335+
}
336+
337+
private func areHighlightProvidersEqual(controller: TextViewController) -> Bool {
338+
controller.highlightProviders.map { ObjectIdentifier($0) } == highlightProviders.map { ObjectIdentifier($0) }
330339
}
331340
}
332341

Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Foundation
99
import SwiftTreeSitter
1010

1111
extension TextViewController {
12-
internal func setUpHighlighter() {
12+
package func setUpHighlighter() {
1313
if let highlighter {
1414
textView.removeStorageDelegate(highlighter)
1515
self.highlighter = nil
@@ -24,6 +24,17 @@ extension TextViewController {
2424
textView.addStorageDelegate(highlighter)
2525
self.highlighter = highlighter
2626
}
27+
28+
/// Sets new highlight providers. Recognizes when objects move in the array or are removed or inserted.
29+
///
30+
/// This is in place of a setter on the ``highlightProviders`` variable to avoid wasting resources setting up
31+
/// providers early.
32+
///
33+
/// - Parameter newProviders: All the new providers.
34+
package func setHighlightProviders(_ newProviders: [HighlightProviding]) {
35+
highlighter?.setProviders(newProviders)
36+
highlightProviders = newProviders
37+
}
2738
}
2839

2940
extension TextViewController: ThemeAttributesProviding {

Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ class HighlightProviderState {
5050
private weak var delegate: HighlightProviderStateDelegate?
5151

5252
/// Calculates invalidated ranges given an edit.
53-
private weak var highlightProvider: HighlightProviding?
53+
/// Marked as package for deduplication when updating highlight providers.
54+
package weak var highlightProvider: HighlightProviding?
5455

5556
/// Provides a constantly updated visible index set.
5657
private weak var visibleRangeProvider: VisibleRangeProvider?

Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ class Highlighter: NSObject {
7878

7979
private var visibleRangeProvider: VisibleRangeProvider
8080

81+
/// Counts upwards to provide unique IDs for new highlight providers.
82+
private var providerIdCounter: Int
83+
8184
// MARK: - Init
8285

8386
init(
@@ -90,10 +93,12 @@ class Highlighter: NSObject {
9093
self.textView = textView
9194
self.attributeProvider = attributeProvider
9295

93-
visibleRangeProvider = VisibleRangeProvider(textView: textView)
96+
self.visibleRangeProvider = VisibleRangeProvider(textView: textView)
9497

9598
let providerIds = providers.indices.map({ $0 })
96-
styleContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds)
99+
self.styleContainer = StyledRangeContainer(documentLength: textView.length, providers: providerIds)
100+
101+
self.providerIdCounter = providers.count
97102

98103
super.init()
99104

@@ -138,6 +143,66 @@ class Highlighter: NSObject {
138143
highlightProviders.forEach { $0.setLanguage(language: language) }
139144
}
140145

146+
/// Updates the highlight providers the highlighter is using, removing any that don't appear in the given array,
147+
/// and setting up any new ones.
148+
///
149+
/// This is essential for working with SwiftUI, as we'd like to allow highlight providers to be added and removed
150+
/// after the view is initialized. For instance after some sort of async registration method.
151+
///
152+
/// - Note: Each provider will be identified by it's object ID.
153+
/// - Parameter providers: All providers to use.
154+
public func setProviders(_ providers: [HighlightProviding]) {
155+
guard let textView else { return }
156+
157+
let existingIds: [ObjectIdentifier] = self.highlightProviders
158+
.compactMap { $0.highlightProvider }
159+
.map { ObjectIdentifier($0) }
160+
let newIds: [ObjectIdentifier] = providers.map { ObjectIdentifier($0) }
161+
// 2nd param is what we're moving *from*. We want to find how we to make existingIDs equal newIDs
162+
let difference = newIds.difference(from: existingIds).inferringMoves()
163+
164+
var highlightProviders = self.highlightProviders // Make a mutable copy
165+
var moveMap: [Int: HighlightProviderState] = [:]
166+
167+
for change in difference {
168+
switch change {
169+
case let .insert(offset, element, associatedOffset):
170+
guard associatedOffset == nil,
171+
let newProvider = providers.first(where: { ObjectIdentifier($0) == element }) else {
172+
// Moved, grab the moved object from the move map
173+
guard let movedProvider = moveMap[offset] else {
174+
continue
175+
}
176+
highlightProviders.insert(movedProvider, at: offset)
177+
continue
178+
}
179+
// Set up a new provider and insert it with a unique ID
180+
providerIdCounter += 1
181+
let state = HighlightProviderState( // This will call setup on the highlight provider
182+
id: providerIdCounter,
183+
delegate: styleContainer,
184+
highlightProvider: newProvider,
185+
textView: textView,
186+
visibleRangeProvider: visibleRangeProvider,
187+
language: language
188+
)
189+
highlightProviders.insert(state, at: offset)
190+
styleContainer.addProvider(providerIdCounter, documentLength: textView.length)
191+
state.invalidate() // Invalidate this new one
192+
case let .remove(offset, _, associatedOffset):
193+
guard associatedOffset == nil else {
194+
// Moved, add it to the move map
195+
moveMap[associatedOffset!] = highlightProviders.remove(at: offset)
196+
continue
197+
}
198+
// Removed entirely
199+
styleContainer.removeProvider(highlightProviders.remove(at: offset).id)
200+
}
201+
}
202+
203+
self.highlightProviders = highlightProviders
204+
}
205+
141206
deinit {
142207
self.attributeProvider = nil
143208
self.textView = nil

Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ class StyledRangeContainer {
3232
}
3333
}
3434

35+
func addProvider(_ id: ProviderID, documentLength: Int) {
36+
assert(!_storage.keys.contains(id), "Provider already exists")
37+
_storage[id] = StyledRangeStore(documentLength: documentLength)
38+
}
39+
40+
func removeProvider(_ id: ProviderID) {
41+
guard let provider = _storage[id] else { return }
42+
applyHighlightResult(
43+
provider: id,
44+
highlights: [],
45+
rangeToHighlight: NSRange(location: 0, length: provider.length)
46+
)
47+
_storage.removeValue(forKey: id)
48+
}
49+
3550
/// Coalesces all styled runs into a single continuous array of styled runs.
3651
///
3752
/// When there is an overlapping, conflicting style (eg: provider 2 gives `.comment` to the range `0..<2`, and

Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeStore/StyledRangeStore.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ final class StyledRangeStore {
1717
typealias Index = Rope<StyledRun>.Index
1818
var _guts = Rope<StyledRun>()
1919

20+
var length: Int {
21+
_guts.count(in: OffsetMetric())
22+
}
23+
2024
/// A small performance improvement for multiple identical queries, as often happens when used
2125
/// in ``StyledRangeContainer``
2226
private var cache: (range: Range<Int>, runs: [Run])?

0 commit comments

Comments
 (0)