Skip to content

Commit 0c04fb5

Browse files
Abstract Highlighter Object (#139)
1 parent e7b530c commit 0c04fb5

File tree

10 files changed

+276
-85
lines changed

10 files changed

+276
-85
lines changed

Sources/CodeEditTextView/CodeEditTextView.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
2323
/// - lineHeight: The line height multiplier (e.g. `1.2`)
2424
/// - wrapLines: Whether lines wrap to the width of the editor
2525
/// - editorOverscroll: The percentage for overscroll, between 0-1 (default: `0.0`)
26+
/// - highlightProvider: A class you provide to perform syntax highlighting. Leave this as `nil` to use the
27+
/// built-in `TreeSitterClient` highlighter.
2628
public init(
2729
_ text: Binding<String>,
2830
language: CodeLanguage,
@@ -33,7 +35,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
3335
wrapLines: Binding<Bool>,
3436
editorOverscroll: Binding<Double> = .constant(0.0),
3537
cursorPosition: Published<(Int, Int)>.Publisher? = nil,
36-
useThemeBackground: Bool = true
38+
useThemeBackground: Bool = true,
39+
highlightProvider: HighlightProviding? = nil
3740
) {
3841
self._text = text
3942
self.language = language
@@ -45,6 +48,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
4548
self._wrapLines = wrapLines
4649
self._editorOverscroll = editorOverscroll
4750
self.cursorPosition = cursorPosition
51+
self.highlightProvider = highlightProvider
4852
}
4953

5054
@Binding private var text: String
@@ -57,6 +61,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
5761
@Binding private var editorOverscroll: Double
5862
private var cursorPosition: Published<(Int, Int)>.Publisher?
5963
private var useThemeBackground: Bool
64+
private var highlightProvider: HighlightProviding?
6065

6166
public typealias NSViewControllerType = STTextViewController
6267

@@ -70,7 +75,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
7075
wrapLines: wrapLines,
7176
cursorPosition: cursorPosition,
7277
editorOverscroll: editorOverscroll,
73-
useThemeBackground: useThemeBackground
78+
useThemeBackground: useThemeBackground,
79+
highlightProvider: highlightProvider
7480
)
7581
controller.lineHeightMultiple = lineHeight
7682
return controller

Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ extension STTextView {
3131
let end = textLayoutManager.textLayoutFragment(for: maxPoint)?.rangeInElement.endLocation else {
3232
return textLayoutManager.documentRange.nsRange(using: textContentStorage)
3333
}
34+
guard start.compare(end) != .orderedDescending else {
35+
return NSTextRange(location: end, end: start)?.nsRange(using: textContentStorage)
36+
}
3437

3538
// Calculate a range and return it as an `NSRange`
3639
return NSTextRange(location: start, end: end)?.nsRange(using: textContentStorage)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// String+encoding.swift
3+
//
4+
//
5+
// Created by Khan Winter on 1/19/23.
6+
//
7+
8+
import Foundation
9+
10+
extension String {
11+
static var nativeUTF16Encoding: String.Encoding {
12+
let dataA = "abc".data(using: .utf16LittleEndian)
13+
let dataB = "abc".data(using: .utf16)?.suffix(from: 2)
14+
15+
return dataA == dataB ? .utf16LittleEndian : .utf16BigEndian
16+
}
17+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// HighlightProviding.swift
3+
//
4+
//
5+
// Created by Khan Winter on 1/18/23.
6+
//
7+
8+
import Foundation
9+
import CodeEditLanguages
10+
import STTextView
11+
import AppKit
12+
13+
/// The protocol a class must conform to to be used for highlighting.
14+
public protocol HighlightProviding {
15+
/// A unique identifier for the highlighter object.
16+
/// Example: `"CodeEdit.TreeSitterHighlighter"`
17+
/// - Note: This does not need to be *globally* unique, merely unique across all the highlighters used.
18+
var identifier: String { get }
19+
20+
/// Updates the highlighter's code language.
21+
/// - Parameters:
22+
/// - codeLanguage: The langugage that should be used by the highlighter.
23+
func setLanguage(codeLanguage: CodeLanguage)
24+
25+
/// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted.
26+
/// The returned `IndexSet` should include all indexes that need to be highlighted, including any inserted text.
27+
/// - Parameters:
28+
/// - textView:The text view to use.
29+
/// - range: The range of the edit.
30+
/// - delta: The length of the edit, can be negative for deletions.
31+
/// - completion: The function to call with an `IndexSet` containing all Indices to invalidate.
32+
func applyEdit(textView: HighlighterTextView,
33+
range: NSRange,
34+
delta: Int,
35+
completion: @escaping ((IndexSet) -> Void))
36+
37+
/// Queries the highlight provider for any ranges to apply highlights to. The highlight provider should return an
38+
/// array containing all ranges to highlight, and the capture type for the range. Any ranges or indexes
39+
/// excluded from the returned array will be treated as plain text and highlighted as such.
40+
/// - Parameters:
41+
/// - textView: The text view to use.
42+
/// - range: The range to operate on.
43+
/// - completion: Function to call with all ranges to highlight
44+
func queryHighlightsFor(textView: HighlighterTextView,
45+
range: NSRange,
46+
completion: @escaping (([HighlightRange]) -> Void))
47+
}

Sources/CodeEditTextView/Highlighting/HighlightRange.swift

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

1010
/// This class represents a range to highlight, as well as the capture name for syntax coloring.
11-
class HighlightRange {
11+
public class HighlightRange {
1212
init(range: NSRange, capture: CaptureName?) {
1313
self.range = range
1414
self.capture = capture

Sources/CodeEditTextView/Highlighting/Highlighter.swift

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,6 @@ import STTextView
1111
import SwiftTreeSitter
1212
import CodeEditLanguages
1313

14-
/// Classes conforming to this protocol can provide attributes for text given a capture type.
15-
public protocol ThemeAttributesProviding {
16-
func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any]
17-
}
18-
1914
/// The `Highlighter` class handles efficiently highlighting the `STTextView` it's provided with.
2015
/// It will listen for text and visibility changes, and highlight syntax as needed.
2116
///
@@ -49,13 +44,21 @@ class Highlighter: NSObject {
4944

5045
/// The text view to highlight
5146
private var textView: STTextView
47+
48+
/// The editor theme
5249
private var theme: EditorTheme
50+
51+
/// The object providing attributes for captures.
5352
private var attributeProvider: ThemeAttributesProviding!
5453

55-
// MARK: - TreeSitter Client
54+
/// The current language of the editor.
55+
private var language: CodeLanguage
5656

5757
/// Calculates invalidated ranges given an edit.
58-
private var treeSitterClient: TreeSitterClient?
58+
private var highlightProvider: HighlightProviding?
59+
60+
/// The length to chunk ranges into when passing to the highlighter.
61+
fileprivate let rangeChunkLimit = 256
5962

6063
// MARK: - Init
6164

@@ -65,17 +68,19 @@ class Highlighter: NSObject {
6568
/// - treeSitterClient: The tree-sitter client to handle tree updates and highlight queries.
6669
/// - theme: The theme to use for highlights.
6770
init(textView: STTextView,
68-
treeSitterClient: TreeSitterClient?,
71+
highlightProvider: HighlightProviding?,
6972
theme: EditorTheme,
70-
attributeProvider: ThemeAttributesProviding) {
73+
attributeProvider: ThemeAttributesProviding,
74+
language: CodeLanguage) {
7175
self.textView = textView
72-
self.treeSitterClient = treeSitterClient
76+
self.highlightProvider = highlightProvider
7377
self.theme = theme
7478
self.attributeProvider = attributeProvider
79+
self.language = language
7580

7681
super.init()
7782

78-
treeSitterClient?.setText(text: textView.string)
83+
highlightProvider?.setLanguage(codeLanguage: language)
7984

8085
guard textView.textContentStorage.textStorage != nil else {
8186
assertionFailure("Text view does not have a textStorage")
@@ -100,17 +105,22 @@ class Highlighter: NSObject {
100105
// MARK: - Public
101106

102107
/// Invalidates all text in the textview. Useful for updating themes.
103-
func invalidate() {
104-
if !(treeSitterClient?.hasSetText ?? true) {
105-
treeSitterClient?.setText(text: textView.string)
106-
}
108+
public func invalidate() {
107109
invalidate(range: NSRange(entireTextRange))
108110
}
109111

110112
/// Sets the language and causes a re-highlight of the entire text.
111113
/// - Parameter language: The language to update to.
112-
func setLanguage(language: CodeLanguage) throws {
113-
try treeSitterClient?.setLanguage(codeLanguage: language, text: textView.string)
114+
public func setLanguage(language: CodeLanguage) {
115+
highlightProvider?.setLanguage(codeLanguage: language)
116+
invalidate()
117+
}
118+
119+
/// Sets the highlight provider. Will cause a re-highlight of the entire text.
120+
/// - Parameter provider: The provider to use for future syntax highlights.
121+
public func setHighlightProvider(_ provider: HighlightProviding) {
122+
self.highlightProvider = provider
123+
highlightProvider?.setLanguage(codeLanguage: language)
114124
invalidate()
115125
}
116126

@@ -155,7 +165,8 @@ private extension Highlighter {
155165
func highlight(range rangeToHighlight: NSRange) {
156166
pendingSet.insert(integersIn: rangeToHighlight)
157167

158-
treeSitterClient?.queryColorsFor(range: rangeToHighlight) { [weak self] highlightRanges in
168+
highlightProvider?.queryHighlightsFor(textView: self.textView,
169+
range: rangeToHighlight) { [weak self] highlightRanges in
159170
guard let attributeProvider = self?.attributeProvider,
160171
let textView = self?.textView else { return }
161172

@@ -222,11 +233,13 @@ private extension Highlighter {
222233
.intersection(visibleSet) // Only visible indexes
223234
.subtracting(pendingSet) // Don't include pending indexes
224235

225-
guard let range = set.rangeView.map({ NSRange($0) }).first else {
236+
guard let range = set.rangeView.first else {
226237
return nil
227238
}
228239

229-
return range
240+
// Chunk the ranges in sets of rangeChunkLimit characters.
241+
return NSRange(location: range.lowerBound,
242+
length: min(rangeChunkLimit, range.upperBound - range.lowerBound))
230243
}
231244

232245
}
@@ -256,7 +269,6 @@ extension Highlighter: NSTextStorageDelegate {
256269
didProcessEditing editedMask: NSTextStorageEditActions,
257270
range editedRange: NSRange,
258271
changeInLength delta: Int) {
259-
260272
// This method is called whenever attributes are updated, so to avoid re-highlighting the entire document
261273
// each time an attribute is applied, we check to make sure this is in response to an edit.
262274
guard editedMask.contains(.editedCharacters) else {
@@ -265,12 +277,9 @@ extension Highlighter: NSTextStorageDelegate {
265277

266278
let range = NSRange(location: editedRange.location, length: editedRange.length - delta)
267279

268-
guard let edit = InputEdit(range: range, delta: delta, oldEndPoint: .zero) else {
269-
return
270-
}
271-
272-
treeSitterClient?.applyEdit(edit,
273-
text: textStorage.string) { [weak self] invalidatedIndexSet in
280+
highlightProvider?.applyEdit(textView: self.textView,
281+
range: range,
282+
delta: delta) { [weak self] invalidatedIndexSet in
274283
let indexSet = invalidatedIndexSet
275284
.union(IndexSet(integersIn: editedRange))
276285
// Only invalidate indices that aren't visible.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// HighlighterTextView.swift
3+
//
4+
//
5+
// Created by Khan Winter on 1/26/23.
6+
//
7+
8+
import Foundation
9+
import AppKit
10+
import STTextView
11+
12+
/// The object `HighlightProviding` objects are given when asked for highlights.
13+
public protocol HighlighterTextView {
14+
/// The entire range of the document.
15+
var documentRange: NSRange { get }
16+
/// A substring for the requested range.
17+
func stringForRange(_ nsRange: NSRange) -> String?
18+
}
19+
20+
/// A default implementation for `STTextView` to be passed to `HighlightProviding` objects.
21+
extension STTextView: HighlighterTextView {
22+
public var documentRange: NSRange {
23+
return NSRange(location: 0,
24+
length: textContentStorage.textStorage?.length ?? 0)
25+
}
26+
27+
public func stringForRange(_ nsRange: NSRange) -> String? {
28+
return textContentStorage.textStorage?.mutableString.substring(with: nsRange)
29+
}
30+
}

0 commit comments

Comments
 (0)