Skip to content

Commit

Permalink
Add TextViewDelegate Option to Coordinators (#265)
Browse files Browse the repository at this point in the history
  • Loading branch information
thecoolwinter authored Sep 21, 2024
1 parent 137abc8 commit 033b68d
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion Sources/CodeEditSourceEditor/Controller/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ public class TextViewController: NSViewController {
}
}

var textCoordinators: [WeakCoordinator] = []

var highlighter: Highlighter?

/// The tree sitter client managed by the source editor.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -254,6 +257,10 @@ public class TextViewController: NSViewController {
useSystemCursor: platformGuardedSystemCursor,
delegate: self
)

coordinators.forEach {
$0.prepareCoordinator(controller: self)
}
}

required init?(coder: NSCoder) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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()

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

textStorage.endEditing()
layoutManager.endTransaction()

delegate?.textView(self, didReplaceContentsIn: mutation.range, with: mutation.string)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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 })
}
}

0 comments on commit 033b68d

Please sign in to comment.