From 033b68d3e3e845984fbc3d405720d5cc6ce61f71 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 21 Sep 2024 08:42:41 -0500 Subject: [PATCH] Add TextViewDelegate Option to Coordinators (#265) --- .../CodeEditSourceEditor+Coordinator.swift | 32 +++++++------------ .../CodeEditSourceEditor.swift | 11 ++++--- .../TextViewController+Cursor.swift | 5 ++- .../TextViewController+TextViewDelegate.swift | 15 +++++++++ .../Controller/TextViewController.swift | 13 +++++++- .../TextViewCoordinators.md | 21 +++++++++++- .../TextView+/TextView+TextFormation.swift | 4 +++ .../TextViewCoordinator.swift | 12 +++++-- .../CursorPosition.swift | 2 +- .../Utils/WeakCoordinator.swift | 25 +++++++++++++++ 10 files changed, 108 insertions(+), 32 deletions(-) rename Sources/CodeEditSourceEditor/{Controller => Utils}/CursorPosition.swift (97%) create mode 100644 Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift index 645c13de3..3f17918d3 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift @@ -6,18 +6,21 @@ // import Foundation +import SwiftUI import CodeEditTextView extension CodeEditSourceEditor { @MainActor public class Coordinator: NSObject { - var parent: CodeEditSourceEditor weak var controller: TextViewController? var isUpdatingFromRepresentable: Bool = false var isUpdateFromTextView: Bool = false + var text: TextAPI + @Binding var cursorPositions: [CursorPosition] - init(parent: CodeEditSourceEditor) { - self.parent = parent + init(text: TextAPI, cursorPositions: Binding<[CursorPosition]>) { + self.text = text + self._cursorPositions = cursorPositions super.init() NotificationCenter.default.addObserver( @@ -41,33 +44,22 @@ extension CodeEditSourceEditor { controller.textView === textView else { return } - if case .binding(let binding) = parent.text { + if case .binding(let binding) = text { binding.wrappedValue = textView.string } - parent.coordinators.forEach { - $0.textViewDidChangeText(controller: controller) - } } @objc func textControllerCursorsDidUpdate(_ notification: Notification) { + guard let notificationController = notification.object as? TextViewController, + notificationController === controller else { + return + } guard !isUpdatingFromRepresentable else { return } self.isUpdateFromTextView = true - self.parent.cursorPositions.wrappedValue = self.controller?.cursorPositions ?? [] - if self.controller != nil { - self.parent.coordinators.forEach { - $0.textViewDidChangeSelection( - controller: self.controller!, - newPositions: self.controller!.cursorPositions - ) - } - } + cursorPositions = notificationController.cursorPositions } deinit { - parent.coordinators.forEach { - $0.destroy() - } - parent.coordinators.removeAll() NotificationCenter.default.removeObserver(self) } } diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 516e965c7..2f856a5d9 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -211,7 +211,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { letterSpacing: letterSpacing, useSystemCursor: useSystemCursor, bracketPairHighlight: bracketPairHighlight, - undoManager: undoManager + undoManager: undoManager, + coordinators: coordinators ) switch text { case .binding(let binding): @@ -227,14 +228,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { } context.coordinator.controller = controller - coordinators.forEach { - $0.prepareCoordinator(controller: controller) - } return controller } public func makeCoordinator() -> Coordinator { - Coordinator(parent: self) + Coordinator(text: text, cursorPositions: cursorPositions) } public func updateNSViewController(_ controller: TextViewController, context: Context) { @@ -247,6 +245,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { context.coordinator.isUpdateFromTextView = false } + // Set this no matter what to avoid having to compare object pointers. + controller.textCoordinators = coordinators.map { WeakCoordinator($0) } + // Do manual diffing to reduce the amount of reloads. // This helps a lot in view performance, as it otherwise gets triggered on each environment change. guard !paramsAreEqual(controller: controller) else { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index 5549a1d6d..de2783f76 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -49,7 +49,10 @@ extension TextViewController { isPostingCursorNotification = true cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location }) - NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: nil) + NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: self) + for coordinator in self.textCoordinators.values() { + coordinator.textViewDidChangeSelection(controller: self, newPositions: cursorPositions) + } isPostingCursorNotification = false } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift index 885671fb8..ec5f82439 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift @@ -10,8 +10,23 @@ import CodeEditTextView import TextStory extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { + for coordinator in self.textCoordinators.values() { + if let coordinator = coordinator as? TextViewDelegate { + coordinator.textView(textView, willReplaceContentsIn: range, with: string) + } + } + } + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { gutterView.needsDisplay = true + for coordinator in self.textCoordinators.values() { + if let coordinator = coordinator as? TextViewDelegate { + coordinator.textView(textView, didReplaceContentsIn: range, with: string) + } else { + coordinator.textViewDidChangeText(controller: self) + } + } } public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index abf4a0ffd..4dbf282a2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -166,6 +166,8 @@ public class TextViewController: NSViewController { } } + var textCoordinators: [WeakCoordinator] = [] + var highlighter: Highlighter? /// The tree sitter client managed by the source editor. @@ -213,7 +215,8 @@ public class TextViewController: NSViewController { letterSpacing: Double, useSystemCursor: Bool, bracketPairHighlight: BracketPairHighlight?, - undoManager: CEUndoManager? = nil + undoManager: CEUndoManager? = nil, + coordinators: [TextViewCoordinator] = [] ) { self.language = language self.font = font @@ -254,6 +257,10 @@ public class TextViewController: NSViewController { useSystemCursor: platformGuardedSystemCursor, delegate: self ) + + coordinators.forEach { + $0.prepareCoordinator(controller: self) + } } required init?(coder: NSCoder) { @@ -292,6 +299,10 @@ public class TextViewController: NSViewController { } highlighter = nil highlightProvider = nil + textCoordinators.values().forEach { + $0.destroy() + } + textCoordinators.removeAll() NotificationCenter.default.removeObserver(self) cancellables.forEach { $0.cancel() } if let localEvenMonitor { diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md b/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md index 8c4b0931d..88a5bc966 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md @@ -4,7 +4,11 @@ Add advanced functionality to CodeEditSourceEditor. ## Overview -CodeEditSourceEditor provides an API to add more advanced functionality to the editor than SwiftUI allows. For instance, a +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. + +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. + +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. ### Make a Coordinator @@ -61,6 +65,21 @@ The lifecycle looks like this: - ``TextViewCoordinator/destroy()-9nzfl`` is called. - CodeEditSourceEditor stops referencing the coordinator. +### TextViewDelegate Conformance + +If a coordinator conforms to the `TextViewDelegate` protocol from the `CodeEditTextView` package, it will receive forwarded delegate messages for the editor's text view. + +The messages it will receive: +```swift +func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) +func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) +``` + +It will _not_ receive the following: +```swift +func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool +``` + ### Example 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. diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 61fd3ef73..99e80effb 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -40,6 +40,8 @@ extension TextView: TextInterface { public func applyMutation(_ mutation: TextMutation) { guard !mutation.isEmpty else { return } + delegate?.textView(self, willReplaceContentsIn: mutation.range, with: mutation.string) + layoutManager.beginTransaction() textStorage.beginEditing() @@ -53,5 +55,7 @@ extension TextView: TextInterface { textStorage.endEditing() layoutManager.endTransaction() + + delegate?.textView(self, didReplaceContentsIn: mutation.range, with: mutation.string) } } diff --git a/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift b/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift index 98c553cd5..c19cf5d11 100644 --- a/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift +++ b/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift @@ -7,11 +7,17 @@ import AppKit -/// # TextViewCoordinator +/// A protocol that can be used to receive extra state change messages from ``CodeEditSourceEditor``. /// -/// A protocol that can be used to provide extra functionality to ``CodeEditSourceEditor/CodeEditSourceEditor`` while -/// avoiding some of the inefficiencies of SwiftUI. +/// These are used as a way to push messages up from underlying components into SwiftUI land without requiring passing +/// callbacks for each message to the ``CodeEditSourceEditor`` initializer. /// +/// 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, see the ``CombineCoordinator`` class, which implements combine publishers for the +/// messages this protocol provides. +/// +/// Conforming objects 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. public protocol TextViewCoordinator: AnyObject { /// Called when an instance of ``TextViewController`` is available. Use this method to install any delegates, /// perform any modifications on the text view or controller, or capture the text view for later use in your app. diff --git a/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift similarity index 97% rename from Sources/CodeEditSourceEditor/Controller/CursorPosition.swift rename to Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 46e4c2c87..287aeb10a 100644 --- a/Sources/CodeEditSourceEditor/Controller/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift @@ -16,7 +16,7 @@ import Foundation /// When initialized by users, certain values may be set to `NSNotFound` or `-1` until they can be filled in by the text /// controller. /// -public struct CursorPosition: Sendable, Codable, Equatable { +public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// Initialize a cursor position. /// /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. diff --git a/Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift b/Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift new file mode 100644 index 000000000..cc5090a12 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift @@ -0,0 +1,25 @@ +// +// WeakCoordinator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 9/13/24. +// + +struct WeakCoordinator { + weak var val: TextViewCoordinator? + + init(_ val: TextViewCoordinator) { + self.val = val + } +} + +extension Array where Element == WeakCoordinator { + mutating func clean() { + self.removeAll(where: { $0.val == nil }) + } + + mutating func values() -> [TextViewCoordinator] { + self.clean() + return self.compactMap({ $0.val }) + } +}