Skip to content

Commit 033b68d

Browse files
Add TextViewDelegate Option to Coordinators (#265)
1 parent 137abc8 commit 033b68d

File tree

10 files changed

+108
-32
lines changed

10 files changed

+108
-32
lines changed

Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66
//
77

88
import Foundation
9+
import SwiftUI
910
import CodeEditTextView
1011

1112
extension CodeEditSourceEditor {
1213
@MainActor
1314
public class Coordinator: NSObject {
14-
var parent: CodeEditSourceEditor
1515
weak var controller: TextViewController?
1616
var isUpdatingFromRepresentable: Bool = false
1717
var isUpdateFromTextView: Bool = false
18+
var text: TextAPI
19+
@Binding var cursorPositions: [CursorPosition]
1820

19-
init(parent: CodeEditSourceEditor) {
20-
self.parent = parent
21+
init(text: TextAPI, cursorPositions: Binding<[CursorPosition]>) {
22+
self.text = text
23+
self._cursorPositions = cursorPositions
2124
super.init()
2225

2326
NotificationCenter.default.addObserver(
@@ -41,33 +44,22 @@ extension CodeEditSourceEditor {
4144
controller.textView === textView else {
4245
return
4346
}
44-
if case .binding(let binding) = parent.text {
47+
if case .binding(let binding) = text {
4548
binding.wrappedValue = textView.string
4649
}
47-
parent.coordinators.forEach {
48-
$0.textViewDidChangeText(controller: controller)
49-
}
5050
}
5151

5252
@objc func textControllerCursorsDidUpdate(_ notification: Notification) {
53+
guard let notificationController = notification.object as? TextViewController,
54+
notificationController === controller else {
55+
return
56+
}
5357
guard !isUpdatingFromRepresentable else { return }
5458
self.isUpdateFromTextView = true
55-
self.parent.cursorPositions.wrappedValue = self.controller?.cursorPositions ?? []
56-
if self.controller != nil {
57-
self.parent.coordinators.forEach {
58-
$0.textViewDidChangeSelection(
59-
controller: self.controller!,
60-
newPositions: self.controller!.cursorPositions
61-
)
62-
}
63-
}
59+
cursorPositions = notificationController.cursorPositions
6460
}
6561

6662
deinit {
67-
parent.coordinators.forEach {
68-
$0.destroy()
69-
}
70-
parent.coordinators.removeAll()
7163
NotificationCenter.default.removeObserver(self)
7264
}
7365
}

Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
211211
letterSpacing: letterSpacing,
212212
useSystemCursor: useSystemCursor,
213213
bracketPairHighlight: bracketPairHighlight,
214-
undoManager: undoManager
214+
undoManager: undoManager,
215+
coordinators: coordinators
215216
)
216217
switch text {
217218
case .binding(let binding):
@@ -227,14 +228,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
227228
}
228229

229230
context.coordinator.controller = controller
230-
coordinators.forEach {
231-
$0.prepareCoordinator(controller: controller)
232-
}
233231
return controller
234232
}
235233

236234
public func makeCoordinator() -> Coordinator {
237-
Coordinator(parent: self)
235+
Coordinator(text: text, cursorPositions: cursorPositions)
238236
}
239237

240238
public func updateNSViewController(_ controller: TextViewController, context: Context) {
@@ -247,6 +245,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
247245
context.coordinator.isUpdateFromTextView = false
248246
}
249247

248+
// Set this no matter what to avoid having to compare object pointers.
249+
controller.textCoordinators = coordinators.map { WeakCoordinator($0) }
250+
250251
// Do manual diffing to reduce the amount of reloads.
251252
// This helps a lot in view performance, as it otherwise gets triggered on each environment change.
252253
guard !paramsAreEqual(controller: controller) else {

Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ extension TextViewController {
4949

5050
isPostingCursorNotification = true
5151
cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location })
52-
NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: nil)
52+
NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: self)
53+
for coordinator in self.textCoordinators.values() {
54+
coordinator.textViewDidChangeSelection(controller: self, newPositions: cursorPositions)
55+
}
5356
isPostingCursorNotification = false
5457
}
5558
}

Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,23 @@ import CodeEditTextView
1010
import TextStory
1111

1212
extension TextViewController: TextViewDelegate {
13+
public func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) {
14+
for coordinator in self.textCoordinators.values() {
15+
if let coordinator = coordinator as? TextViewDelegate {
16+
coordinator.textView(textView, willReplaceContentsIn: range, with: string)
17+
}
18+
}
19+
}
20+
1321
public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) {
1422
gutterView.needsDisplay = true
23+
for coordinator in self.textCoordinators.values() {
24+
if let coordinator = coordinator as? TextViewDelegate {
25+
coordinator.textView(textView, didReplaceContentsIn: range, with: string)
26+
} else {
27+
coordinator.textViewDidChangeText(controller: self)
28+
}
29+
}
1530
}
1631

1732
public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool {

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ public class TextViewController: NSViewController {
166166
}
167167
}
168168

169+
var textCoordinators: [WeakCoordinator] = []
170+
169171
var highlighter: Highlighter?
170172

171173
/// The tree sitter client managed by the source editor.
@@ -213,7 +215,8 @@ public class TextViewController: NSViewController {
213215
letterSpacing: Double,
214216
useSystemCursor: Bool,
215217
bracketPairHighlight: BracketPairHighlight?,
216-
undoManager: CEUndoManager? = nil
218+
undoManager: CEUndoManager? = nil,
219+
coordinators: [TextViewCoordinator] = []
217220
) {
218221
self.language = language
219222
self.font = font
@@ -254,6 +257,10 @@ public class TextViewController: NSViewController {
254257
useSystemCursor: platformGuardedSystemCursor,
255258
delegate: self
256259
)
260+
261+
coordinators.forEach {
262+
$0.prepareCoordinator(controller: self)
263+
}
257264
}
258265

259266
required init?(coder: NSCoder) {
@@ -292,6 +299,10 @@ public class TextViewController: NSViewController {
292299
}
293300
highlighter = nil
294301
highlightProvider = nil
302+
textCoordinators.values().forEach {
303+
$0.destroy()
304+
}
305+
textCoordinators.removeAll()
295306
NotificationCenter.default.removeObserver(self)
296307
cancellables.forEach { $0.cancel() }
297308
if let localEvenMonitor {

Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ Add advanced functionality to CodeEditSourceEditor.
44

55
## Overview
66

7-
CodeEditSourceEditor provides an API to add more advanced functionality to the editor than SwiftUI allows. For instance, a
7+
CodeEditSourceEditor provides this API as a way to push messages up from underlying components into SwiftUI land without requiring passing callbacks for each message to the ``CodeEditSourceEditor`` initializer.
8+
9+
They're very useful for updating UI that is directly related to the state of the editor, such as the current cursor position. For an example of how this can be useful, see the ``CombineCoordinator`` class, which implements combine publishers for the messages this protocol provides.
10+
11+
They can also be used to get more detailed text editing notifications by conforming to the `TextViewDelegate` (from CodeEditTextView) protocol. In that case they'll receive most text change notifications.
812

913
### Make a Coordinator
1014

@@ -61,6 +65,21 @@ The lifecycle looks like this:
6165
- ``TextViewCoordinator/destroy()-9nzfl`` is called.
6266
- CodeEditSourceEditor stops referencing the coordinator.
6367

68+
### TextViewDelegate Conformance
69+
70+
If a coordinator conforms to the `TextViewDelegate` protocol from the `CodeEditTextView` package, it will receive forwarded delegate messages for the editor's text view.
71+
72+
The messages it will receive:
73+
```swift
74+
func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String)
75+
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String)
76+
```
77+
78+
It will _not_ receive the following:
79+
```swift
80+
func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool
81+
```
82+
6483
### Example
6584

6685
To see an example of a coordinator and they're use case, see the ``CombineCoordinator`` class. This class creates a coordinator that passes notifications on to a Combine stream.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ extension TextView: TextInterface {
4040
public func applyMutation(_ mutation: TextMutation) {
4141
guard !mutation.isEmpty else { return }
4242

43+
delegate?.textView(self, willReplaceContentsIn: mutation.range, with: mutation.string)
44+
4345
layoutManager.beginTransaction()
4446
textStorage.beginEditing()
4547

@@ -53,5 +55,7 @@ extension TextView: TextInterface {
5355

5456
textStorage.endEditing()
5557
layoutManager.endTransaction()
58+
59+
delegate?.textView(self, didReplaceContentsIn: mutation.range, with: mutation.string)
5660
}
5761
}

Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77

88
import AppKit
99

10-
/// # TextViewCoordinator
10+
/// A protocol that can be used to receive extra state change messages from ``CodeEditSourceEditor``.
1111
///
12-
/// A protocol that can be used to provide extra functionality to ``CodeEditSourceEditor/CodeEditSourceEditor`` while
13-
/// avoiding some of the inefficiencies of SwiftUI.
12+
/// These are used as a way to push messages up from underlying components into SwiftUI land without requiring passing
13+
/// callbacks for each message to the ``CodeEditSourceEditor`` initializer.
1414
///
15+
/// They're very useful for updating UI that is directly related to the state of the editor, such as the current
16+
/// cursor position. For an example, see the ``CombineCoordinator`` class, which implements combine publishers for the
17+
/// messages this protocol provides.
18+
///
19+
/// Conforming objects can also be used to get more detailed text editing notifications by conforming to the
20+
/// `TextViewDelegate` (from CodeEditTextView) protocol. In that case they'll receive most text change notifications.
1521
public protocol TextViewCoordinator: AnyObject {
1622
/// Called when an instance of ``TextViewController`` is available. Use this method to install any delegates,
1723
/// perform any modifications on the text view or controller, or capture the text view for later use in your app.

Sources/CodeEditSourceEditor/Controller/CursorPosition.swift renamed to Sources/CodeEditSourceEditor/Utils/CursorPosition.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import Foundation
1616
/// When initialized by users, certain values may be set to `NSNotFound` or `-1` until they can be filled in by the text
1717
/// controller.
1818
///
19-
public struct CursorPosition: Sendable, Codable, Equatable {
19+
public struct CursorPosition: Sendable, Codable, Equatable, Hashable {
2020
/// Initialize a cursor position.
2121
///
2222
/// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// WeakCoordinator.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 9/13/24.
6+
//
7+
8+
struct WeakCoordinator {
9+
weak var val: TextViewCoordinator?
10+
11+
init(_ val: TextViewCoordinator) {
12+
self.val = val
13+
}
14+
}
15+
16+
extension Array where Element == WeakCoordinator {
17+
mutating func clean() {
18+
self.removeAll(where: { $0.val == nil })
19+
}
20+
21+
mutating func values() -> [TextViewCoordinator] {
22+
self.clean()
23+
return self.compactMap({ $0.val })
24+
}
25+
}

0 commit comments

Comments
 (0)