diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift new file mode 100644 index 000000000..645c13de3 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift @@ -0,0 +1,74 @@ +// +// CodeEditSourceEditor+Coordinator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/20/24. +// + +import Foundation +import CodeEditTextView + +extension CodeEditSourceEditor { + @MainActor + public class Coordinator: NSObject { + var parent: CodeEditSourceEditor + weak var controller: TextViewController? + var isUpdatingFromRepresentable: Bool = false + var isUpdateFromTextView: Bool = false + + init(parent: CodeEditSourceEditor) { + self.parent = parent + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(textViewDidChangeText(_:)), + name: TextView.textDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textControllerCursorsDidUpdate(_:)), + name: TextViewController.cursorPositionUpdatedNotification, + object: nil + ) + } + + @objc func textViewDidChangeText(_ notification: Notification) { + guard let textView = notification.object as? TextView, + let controller, + controller.textView === textView else { + return + } + if case .binding(let binding) = parent.text { + binding.wrappedValue = textView.string + } + parent.coordinators.forEach { + $0.textViewDidChangeText(controller: controller) + } + } + + @objc func textControllerCursorsDidUpdate(_ notification: Notification) { + 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 + ) + } + } + } + + deinit { + parent.coordinators.forEach { + $0.destroy() + } + parent.coordinators.removeAll() + NotificationCenter.default.removeObserver(self) + } + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift similarity index 65% rename from Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift rename to Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 99221d9b1..cf1f5e4dd 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -5,11 +5,17 @@ // Created by Lukas Pistrol on 24.05.22. // +import AppKit import SwiftUI import CodeEditTextView import CodeEditLanguages +/// A SwiftUI View that provides source editing functionality. public struct CodeEditSourceEditor: NSViewControllerRepresentable { + package enum TextAPI { + case binding(Binding) + case storage(NSTextStorage) + } /// Initializes a Text Editor /// - Parameters: @@ -22,7 +28,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - lineHeight: The line height multiplier (e.g. `1.2`) /// - wrapLines: Whether lines wrap to the width of the editor /// - editorOverscroll: The distance to overscroll the editor by. - /// - cursorPosition: The cursor's position in the editor, measured in `(lineNum, columnNum)` + /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent /// background color /// - highlightProvider: A class you provide to perform syntax highlighting. Leave this as `nil` to use the @@ -37,6 +43,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager + /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( _ text: Binding, language: CodeLanguage, @@ -58,7 +65,76 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] ) { - self._text = text + self.text = .binding(text) + self.language = language + self.theme = theme + self.useThemeBackground = useThemeBackground + self.font = font + self.tabWidth = tabWidth + self.indentOption = indentOption + self.lineHeight = lineHeight + self.wrapLines = wrapLines + self.editorOverscroll = editorOverscroll + self.cursorPositions = cursorPositions + self.highlightProvider = highlightProvider + self.contentInsets = contentInsets + self.isEditable = isEditable + self.isSelectable = isSelectable + self.letterSpacing = letterSpacing + self.bracketPairHighlight = bracketPairHighlight + self.undoManager = undoManager + self.coordinators = coordinators + } + + /// Initializes a Text Editor + /// - Parameters: + /// - text: The text content + /// - language: The language for syntax highlighting + /// - theme: The theme for syntax highlighting + /// - font: The default font + /// - tabWidth: The visual tab width in number of spaces + /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. + /// - lineHeight: The line height multiplier (e.g. `1.2`) + /// - wrapLines: Whether lines wrap to the width of the editor + /// - editorOverscroll: The distance to overscroll the editor by. + /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` + /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent + /// background color + /// - highlightProvider: A class you provide to perform syntax highlighting. Leave this as `nil` to use the + /// built-in `TreeSitterClient` highlighter. + /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the + /// scroll view automatically adjust content insets. + /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. + /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this + /// value is true, and `isEditable` is false, the editor is selectable but not editable. + /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a + /// character's width between characters, etc. Defaults to `1.0` + /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. + /// See `BracketPairHighlight` for more information. Defaults to `nil` + /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager + /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. + public init( + _ text: NSTextStorage, + language: CodeLanguage, + theme: EditorTheme, + font: NSFont, + tabWidth: Int, + indentOption: IndentOption = .spaces(count: 4), + lineHeight: Double, + wrapLines: Bool, + editorOverscroll: CGFloat = 0, + cursorPositions: Binding<[CursorPosition]>, + useThemeBackground: Bool = true, + highlightProvider: HighlightProviding? = nil, + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool = true, + isSelectable: Bool = true, + letterSpacing: Double = 1.0, + bracketPairHighlight: BracketPairHighlight? = nil, + undoManager: CEUndoManager? = nil, + coordinators: [any TextViewCoordinator] = [] + ) { + self.text = .storage(text) self.language = language self.theme = theme self.useThemeBackground = useThemeBackground @@ -68,7 +144,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.lineHeight = lineHeight self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll - self._cursorPositions = cursorPositions + self.cursorPositions = cursorPositions self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable @@ -79,7 +155,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.coordinators = coordinators } - @Binding private var text: String + package var text: TextAPI private var language: CodeLanguage private var theme: EditorTheme private var font: NSFont @@ -88,7 +164,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var lineHeight: Double private var wrapLines: Bool private var editorOverscroll: CGFloat - @Binding private var cursorPositions: [CursorPosition] + package var cursorPositions: Binding<[CursorPosition]> private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? @@ -97,13 +173,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? private var undoManager: CEUndoManager? - private var coordinators: [any TextViewCoordinator] + package var coordinators: [any TextViewCoordinator] public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( - string: text, + string: "", language: language, font: font, theme: theme, @@ -111,7 +187,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { indentOption: indentOption, lineHeight: lineHeight, wrapLines: wrapLines, - cursorPositions: cursorPositions, + cursorPositions: cursorPositions.wrappedValue, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, @@ -122,11 +198,17 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { bracketPairHighlight: bracketPairHighlight, undoManager: undoManager ) + switch text { + case .binding(let binding): + controller.textView.setText(binding.wrappedValue) + case .storage(let textStorage): + controller.textView.setTextStorage(textStorage) + } if controller.textView == nil { controller.loadView() } if !cursorPositions.isEmpty { - controller.setCursorPositions(cursorPositions) + controller.setCursorPositions(cursorPositions.wrappedValue) } context.coordinator.controller = controller @@ -144,7 +226,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications context.coordinator.isUpdatingFromRepresentable = true - controller.setCursorPositions(cursorPositions) + controller.setCursorPositions(cursorPositions.wrappedValue) context.coordinator.isUpdatingFromRepresentable = false } else { context.coordinator.isUpdateFromTextView = false @@ -216,67 +298,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing == letterSpacing && controller.bracketPairHighlight == bracketPairHighlight } - - @MainActor - public class Coordinator: NSObject { - var parent: CodeEditSourceEditor - weak var controller: TextViewController? - var isUpdatingFromRepresentable: Bool = false - var isUpdateFromTextView: Bool = false - - init(parent: CodeEditSourceEditor) { - self.parent = parent - super.init() - - NotificationCenter.default.addObserver( - self, - selector: #selector(textViewDidChangeText(_:)), - name: TextView.textDidChangeNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(textControllerCursorsDidUpdate(_:)), - name: TextViewController.cursorPositionUpdatedNotification, - object: nil - ) - } - - @objc func textViewDidChangeText(_ notification: Notification) { - guard let textView = notification.object as? TextView, - let controller, - controller.textView === textView else { - return - } - parent.text = textView.string - parent.coordinators.forEach { - $0.textViewDidChangeText(controller: controller) - } - } - - @objc func textControllerCursorsDidUpdate(_ notification: Notification) { - 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 - ) - } - } - } - - deinit { - parent.coordinators.forEach { - $0.destroy() - } - parent.coordinators.removeAll() - NotificationCenter.default.removeObserver(self) - } - } } // swiftlint:disable:next line_length diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditTextView.md b/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md similarity index 100% rename from Sources/CodeEditSourceEditor/Documentation.docc/CodeEditTextView.md rename to Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md index 7b86601cd..39f1102e6 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md @@ -4,36 +4,50 @@ A code editor with syntax highlighting powered by tree-sitter. ## Overview -![logo](codeedittextview-logo) +![logo](codeeditsourceeditor-logo) An Xcode-inspired code editor view written in Swift powered by tree-sitter for [CodeEdit](https://github.com/CodeEditApp/CodeEdit). Features include syntax highlighting (based on the provided theme), code completion, find and replace, text diff, validation, current line highlighting, minimap, inline messages (warnings and errors), bracket matching, and more. -This package includes both `AppKit` and `SwiftUI` components. It also relies on the `CodeEditLanguages` and `Theme` module. - ![banner](preview) -## Syntax Highlighting +This package includes both `AppKit` and `SwiftUI` components. It also relies on the [`CodeEditLanguages`](https://github.com/CodeEditApp/CodeEditLanguages) for optional syntax highlighting using tree-sitter. + +> **CodeEditSourceEditor is currently in development and it is not ready for production use.**
Please check back later for updates on this project. Contributors are welcome as we build out the features mentioned above! -``CodeEditSourceEditor`` uses `tree-sitter` for syntax highlighting. A list of already supported languages can be found [here](https://github.com/CodeEditApp/CodeEditSourceEditor/issues/15). +## Currently Supported Languages -New languages need to be added to the [CodeEditLanguages](https://github.com/CodeEditApp/CodeEditLanguages) repo. +See this issue [CodeEditLanguages#10](https://github.com/CodeEditApp/CodeEditLanguages/issues/10) on `CodeEditLanguages` for more information on supported languages. ## Dependencies -Special thanks to both [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) & [Matt Massicotte](https://twitter.com/mattie) for the great work they've done! +Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! | Package | Source | Author | -| - | - | - | -| `STTextView` | [GitHub](https://github.com/krzyzanowskim/STTextView) | [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) | +| :- | :- | :- | | `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | +## License + +Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/main/LICENSE.md). + ## Topics ### Text View - ``CodeEditSourceEditor/CodeEditSourceEditor`` -- ``CodeEditSourceEditor/TextViewController`` +- ``TextViewController`` +- ``GutterView`` -### Theme +### Themes - ``EditorTheme`` + +### Text Coordinators + +- +- ``TextViewCoordinator`` +- ``CombineCoordinator`` + +### Cursors + +- ``CursorPosition`` diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeeditsourceeditor-logo@2x.png b/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeeditsourceeditor-logo@2x.png new file mode 100644 index 000000000..6bb503b9b Binary files /dev/null and b/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeeditsourceeditor-logo@2x.png differ diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeedittextview-logo.png b/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeedittextview-logo.png deleted file mode 100644 index 8a457f7a0..000000000 Binary files a/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeedittextview-logo.png and /dev/null differ diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md b/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md new file mode 100644 index 000000000..8c4b0931d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md @@ -0,0 +1,66 @@ +# TextView Coordinators + +Add advanced functionality to CodeEditSourceEditor. + +## Overview + +CodeEditSourceEditor provides an API to add more advanced functionality to the editor than SwiftUI allows. For instance, a + +### Make a Coordinator + +To create a coordinator, first create a class that conforms to the ``TextViewCoordinator`` protocol. + +```swift +class MyCoordinator { + func prepareCoordinator(controller: TextViewController) { + // Do any setup, such as keeping a (weak) reference to the controller or adding a text storage delegate. + } +} +``` + +Add any methods required for your coordinator to work, such as receiving notifications when text is edited, or + +```swift +class MyCoordinator { + func prepareCoordinator(controller: TextViewController) { /* ... */ } + + func textViewDidChangeText(controller: TextViewController) { + // Text was updated. + } + + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + // Selections were changed + } +} +``` + +If your coordinator keeps any references to anything in CodeEditSourceEditor, make sure to dereference them using the ``TextViewCoordinator/destroy()-9nzfl`` method. + +```swift +class MyCoordinator { + func prepareCoordinator(controller: TextViewController) { /* ... */ } + func textViewDidChangeText(controller: TextViewController) { /* ... */ } + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { /* ... */ } + + func destroy() { + // Release any resources, `nil` any weak variables, remove delegates, etc. + } +} +``` + +### Coordinator Lifecycle + +A coordinator makes no assumptions about initialization, leaving the developer to pass any init parameters to the coordinator. + +The lifecycle looks like this: +- Coordinator initialized (by user, not CodeEditSourceEditor). +- Coordinator given to CodeEditSourceEditor. + - ``TextViewCoordinator/prepareCoordinator(controller:)`` is called. +- Events occur, coordinators are notified in the order they were passed to CodeEditSourceEditor. +- CodeEditSourceEditor is being closed. + - ``TextViewCoordinator/destroy()-9nzfl`` is called. + - CodeEditSourceEditor stops referencing the coordinator. + +### 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/TextViewCoordinator/CombineCoordinator.swift b/Sources/CodeEditSourceEditor/TextViewCoordinator/CombineCoordinator.swift new file mode 100644 index 000000000..3474c3fd5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/TextViewCoordinator/CombineCoordinator.swift @@ -0,0 +1,48 @@ +// +// CombineCoordinator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/19/24. +// + +import Foundation +import Combine +import CodeEditTextView + +/// A ``TextViewCoordinator`` class that publishes text changes and selection changes using Combine publishers. +/// +/// This class provides two publisher streams: ``textUpdatePublisher`` and ``selectionUpdatePublisher``. +/// Both streams will receive any updates for text edits or selection changes and a `.finished` completion when the +/// source editor is destroyed. +public class CombineCoordinator: TextViewCoordinator { + /// Publishes edit notifications as the text is changed in the editor. + public var textUpdatePublisher: AnyPublisher { + updateSubject.eraseToAnyPublisher() + } + + /// Publishes cursor changes as the user types or selects text. + public var selectionUpdatePublisher: AnyPublisher<[CursorPosition], Never> { + selectionSubject.eraseToAnyPublisher() + } + + private let updateSubject: PassthroughSubject = .init() + private let selectionSubject: CurrentValueSubject<[CursorPosition], Never> = .init([]) + + /// Initializes the coordinator. + public init() { } + + public func prepareCoordinator(controller: TextViewController) { } + + public func textViewDidChangeText(controller: TextViewController) { + updateSubject.send() + } + + public func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + selectionSubject.send(newPositions) + } + + public func destroy() { + updateSubject.send(completion: .finished) + selectionSubject.send(completion: .finished) + } +} diff --git a/Sources/CodeEditSourceEditor/TextViewCoordinator.swift b/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift similarity index 100% rename from Sources/CodeEditSourceEditor/TextViewCoordinator.swift rename to Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift