diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ed390550e..c511a9f74 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", - "version" : "0.11.2" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 4539c2018..bd3a6ead7 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -19,7 +19,9 @@ struct ContentView: View { @State private var language: CodeLanguage = .default @State private var theme: EditorTheme = .light - @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] + @State private var editorState = SourceEditorState( + cursorPositions: [CursorPosition(line: 1, column: 1)] + ) @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @@ -67,7 +69,7 @@ struct ContentView: View { warningCharacters: warningCharacters ) ), - cursorPositions: $cursorPositions + state: $editorState ) .overlay(alignment: .bottom) { StatusBar( @@ -75,7 +77,7 @@ struct ContentView: View { document: $document, wrapLines: $wrapLines, useSystemCursor: $useSystemCursor, - cursorPositions: $cursorPositions, + state: $editorState, isInLongParse: $isInLongParse, language: $language, theme: $theme, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 82c70e696..262f46756 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -18,7 +18,7 @@ struct StatusBar: View { @Binding var document: CodeEditSourceEditorExampleDocument @Binding var wrapLines: Bool @Binding var useSystemCursor: Bool - @Binding var cursorPositions: [CursorPosition] + @Binding var state: SourceEditorState @Binding var isInLongParse: Bool @Binding var language: CodeLanguage @Binding var theme: EditorTheme @@ -100,11 +100,29 @@ struct StatusBar: View { .controlSize(.small) Text("Parsing Document") } - } else { - Text(getLabel(cursorPositions)) } + scrollPosition + Text(getLabel(state.cursorPositions)) + } + .foregroundStyle(.secondary) + + Divider() + .frame(height: 12) + + Text(state.findText ?? "") + .frame(maxWidth: 30) + .lineLimit(1) + .truncationMode(.head) + .foregroundStyle(.secondary) + + Button { + state.findPanelVisible.toggle() + } label: { + Text(state.findPanelVisible ? "Hide" : "Show") + Text(" Find") } + .buttonStyle(.borderless) .foregroundStyle(.secondary) + Divider() .frame(height: 12) LanguagePicker(language: $language) @@ -133,6 +151,39 @@ struct StatusBar: View { } } + var formatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + formatter.allowsFloats = true + return formatter + } + + @ViewBuilder private var scrollPosition: some View { + HStack(spacing: 0) { + Text("{") + TextField( + "", + value: Binding(get: { Double(state.scrollPosition?.x ?? 0.0) }, set: { state.scrollPosition?.x = $0 }), + formatter: formatter + ) + .textFieldStyle(.plain) + .labelsHidden() + .fixedSize() + Text(",") + TextField( + "", + value: Binding(get: { Double(state.scrollPosition?.y ?? 0.0) }, set: { state.scrollPosition?.y = $0 }), + formatter: formatter + ) + .textFieldStyle(.plain) + .labelsHidden() + .fixedSize() + Text("}") + } + } + private func detectLanguage(fileURL: URL?) -> CodeLanguage? { guard let fileURL else { return nil } return CodeLanguage.detectLanguageFrom( diff --git a/README.md b/README.md index 67d1c025c..25f83d8a2 100644 --- a/README.md +++ b/README.md @@ -38,20 +38,28 @@ This package is fully documented [here](https://codeeditapp.github.io/CodeEditSo ## Usage (SwiftUI) +CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. The SwiftUI API provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position. + +For more complex features that require access to the underlying text view or text storage, we've developed the API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings. + ```swift import CodeEditSourceEditor struct ContentView: View { @State var text = "let x = 1.0" - /// Automatically updates with cursor positions, or update the binding to set the user's cursors. - @State var cursorPositions: [CursorPosition] = [] + /// Automatically updates with cursor positions, scroll position, find panel text. + /// Everything in this object is two-way, use it to update cursor positions, scroll position, etc. + @State var editorState = SourceEditorState() /// Configure the editor's appearance, features, and editing behavior... @State var theme = EditorTheme(...) @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) @State var indentOption = .spaces(count: 4) + /// *Powerful* customization options with our text view coordinators API + @State var autoCompleteCoordinator = AutoCompleteCoordinator() + var body: some View { SourceEditor( $text, @@ -61,9 +69,27 @@ struct ContentView: View { appearance: .init(theme: theme, font: font), behavior: .init(indentOption: indentOption) ), - cursorPositions: $cursorPositions + state: $editorState, + coordinators: [autoCompleteCoordinator] ) } + + /// Autocompletes "Hello" to "Hello world!" whenever it's typed. + final class AutoCompleteCoordinator: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) { } + + func textViewDidChangeText(controller: TextViewController) { + for cursorPosition in controller.cursorPositions where cursorPosition.range.location >= 5 { + let location = cursorPosition.range.location + let previousRange = NSRange(start: location - 5, end: location) + let string = (controller.text as NSString).substring(with: previousRange) + + if string.lowercased() == "hello" { + controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!") + } + } + } + } } ``` diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 2a6271201..908d953c0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -102,6 +102,7 @@ extension TextViewController { guard let clipView = notification.object as? NSClipView else { return } self?.gutterView.needsDisplay = true self?.minimapXConstraint?.constant = clipView.bounds.origin.x + NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self) } } @@ -114,6 +115,7 @@ extension TextViewController { self?.gutterView.needsDisplay = true self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) self?.updateTextInsets() + NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self) } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 92872d8e7..b7234221f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -19,6 +19,10 @@ import TextFormation public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + // swiftlint:disable:next line_length + public static let scrollPositionDidUpdateNotification: Notification.Name = .init("TextViewController.scrollPositionDidUpdateNotification") + + // MARK: - Views and Child VCs // MARK: - Views and Child VCs diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md index 4a977024b..b3cec1725 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md @@ -2,7 +2,9 @@ ## Usage -CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. +CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. We provide a fast and efficient SwiftUI API that avoids unnecessary view updates whenever possible. It also provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position. + +For more complex features that require access to the underlying text view or text storage, we've developed the API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings. #### SwiftUI @@ -12,11 +14,12 @@ import CodeEditSourceEditor struct ContentView: View { @State var text = "let x = 1.0" - // For large documents use (avoids SwiftUI inneficiency) + // For large documents use a text storage object (avoids SwiftUI comparisons) // var text: NSTextStorage - /// Automatically updates with cursor positions, or update the binding to set the user's cursors. - @State var cursorPositions: [CursorPosition] = [] + /// Automatically updates with cursor positions, scroll position, find panel text. + /// Everything in this object is two-way, use it to update cursor positions, scroll position, etc. + @State var editorState = SourceEditorState() /// Configure the editor's appearance, features, and editing behavior... @State var theme = EditorTheme(...) @@ -25,6 +28,9 @@ struct ContentView: View { @State var editorOverscroll = 0.3 @State var showMinimap = true + /// *Powerful* customization options with text coordinators + @State var autoCompleteCoordinator = AutoCompleteCoordinator() + var body: some View { SourceEditor( $text, @@ -35,9 +41,27 @@ struct ContentView: View { layout: .init(editorOverscroll: editorOverscroll), peripherals: .init(showMinimap: showMinimap) ), - cursorPositions: $cursorPositions + state: $editorState, + coordinators: [autoCompleteCoordinator] ) } + + /// Autocompletes "Hello" to "Hello world!" whenever it's typed. + class AutoCompleteCoordinator: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) { } + + func textViewDidChangeText(controller: TextViewController) { + for cursorPosition in controller.cursorPositions.reversed() where cursorPosition.range.location >= 5 { + let location = cursorPosition.range.location + let previousRange = NSRange(start: location - 5, end: location) + let string = (controller.text as NSString).substring(with: previousRange) + + if string.lowercased() == "hello" { + controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!") + } + } + } + } } ``` diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index bfea53c92..d607af3eb 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -44,6 +44,11 @@ extension FindViewController { viewModel.isFocused = true findPanel.addEventMonitor() + + NotificationCenter.default.post( + name: FindPanelViewModel.Notifications.didToggle, + object: viewModel.target + ) } /// Hide the find panel @@ -70,6 +75,11 @@ extension FindViewController { if let target = viewModel.target { _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } + + NotificationCenter.default.post( + name: FindPanelViewModel.Notifications.didToggle, + object: viewModel.target + ) } /// Performs an animation with a completion handler, conditionally animating the changes. diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index c32be4b8b..8d859b183 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -60,6 +60,9 @@ struct FindPanelView: View { .onChange(of: viewModel.findText) { _ in viewModel.findTextDidChange() } + .onChange(of: viewModel.replaceText) { _ in + viewModel.replaceTextDidChange() + } .onChange(of: viewModel.wrapAround) { _ in viewModel.find() } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 6bbb02816..e079783d4 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -10,6 +10,12 @@ import Combine import CodeEditTextView class FindPanelViewModel: ObservableObject { + enum Notifications { + static let textDidChange = Notification.Name("FindPanelViewModel.textDidChange") + static let replaceTextDidChange = Notification.Name("FindPanelViewModel.replaceTextDidChange") + static let didToggle = Notification.Name("FindPanelViewModel.didToggle") + } + weak var target: FindPanelTarget? var dismiss: (() -> Void)? @@ -99,5 +105,11 @@ class FindPanelViewModel: ObservableObject { // Clear existing emphases before performing new find target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) find() + + NotificationCenter.default.post(name: Self.Notifications.textDidChange, object: target) + } + + func replaceTextDidChange() { + NotificationCenter.default.post(name: Self.Notifications.replaceTextDidChange, object: target) } } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 3e74b5137..fe7420f95 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -5,42 +5,112 @@ // Created by Khan Winter on 5/20/24. // -import Foundation +import AppKit import SwiftUI +import Combine import CodeEditTextView extension SourceEditor { @MainActor public class Coordinator: NSObject { - weak var controller: TextViewController? + private weak var controller: TextViewController? var isUpdatingFromRepresentable: Bool = false var isUpdateFromTextView: Bool = false var text: TextAPI - @Binding var cursorPositions: [CursorPosition] + @Binding var editorState: SourceEditorState private(set) var highlightProviders: [any HighlightProviding] - init(text: TextAPI, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]?) { + private var cancellables: Set = [] + + init(text: TextAPI, editorState: Binding, highlightProviders: [any HighlightProviding]?) { self.text = text - self._cursorPositions = cursorPositions + self._editorState = editorState self.highlightProviders = highlightProviders ?? [TreeSitterClient()] super.init() + } + + func setController(_ controller: TextViewController) { + self.controller = controller + // swiftlint:disable:next notification_center_detachment + NotificationCenter.default.removeObserver(self) + listenToTextViewNotifications(controller: controller) + listenToCursorNotifications(controller: controller) + listenToFindNotifications(controller: controller) + } + + // MARK: - Listeners + /// Listen to anything related to the text view. + func listenToTextViewNotifications(controller: TextViewController) { NotificationCenter.default.addObserver( self, selector: #selector(textViewDidChangeText(_:)), name: TextView.textDidChangeNotification, - object: nil + object: controller.textView ) + // Needs to be put on the main runloop or SwiftUI gets mad about updating state during view updates. + NotificationCenter.default + .publisher( + for: TextViewController.scrollPositionDidUpdateNotification, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerScrollDidChange(notification) + } + .store(in: &cancellables) + } + + /// Listen to the cursor publisher on the text view controller. + func listenToCursorNotifications(controller: TextViewController) { NotificationCenter.default.addObserver( self, selector: #selector(textControllerCursorsDidUpdate(_:)), name: TextViewController.cursorPositionUpdatedNotification, - object: nil + object: controller ) } + /// Listen to all find panel notifications. + func listenToFindNotifications(controller: TextViewController) { + NotificationCenter.default + .publisher( + for: FindPanelViewModel.Notifications.textDidChange, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerFindTextDidChange(notification) + } + .store(in: &cancellables) + + NotificationCenter.default + .publisher( + for: FindPanelViewModel.Notifications.replaceTextDidChange, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerReplaceTextDidChange(notification) + } + .store(in: &cancellables) + + NotificationCenter.default + .publisher( + for: FindPanelViewModel.Notifications.didToggle, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerFindDidToggle(notification) + } + .store(in: &cancellables) + } + + // MARK: - Update Published State + func updateHighlightProviders(_ highlightProviders: [any HighlightProviding]?) { guard let highlightProviders else { return // Keep our default `TreeSitterClient` if they're `nil` @@ -50,24 +120,60 @@ extension SourceEditor { } @objc func textViewDidChangeText(_ notification: Notification) { - guard let textView = notification.object as? TextView, - let controller, - controller.textView === textView else { + guard let textView = notification.object as? TextView else { return } + // A plain string binding is one-way (from this view, up the hierarchy) so it's not in the state binding if case .binding(let binding) = text { binding.wrappedValue = textView.string } } @objc func textControllerCursorsDidUpdate(_ notification: Notification) { - guard let notificationController = notification.object as? TextViewController, - notificationController === controller else { + guard let controller = notification.object as? TextViewController else { + return + } + updateState { $0.cursorPositions = controller.cursorPositions } + } + + func textControllerScrollDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController else { + return + } + let currentPosition = controller.scrollView.contentView.bounds.origin + if editorState.scrollPosition != currentPosition { + updateState { $0.scrollPosition = currentPosition } + } + } + + func textControllerFindTextDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { + return + } + updateState { $0.findText = findModel.findText } + } + + func textControllerReplaceTextDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { + return + } + updateState { $0.replaceText = findModel.replaceText } + } + + func textControllerFindDidToggle(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { return } + updateState { $0.findPanelVisible = findModel.isShowingFindPanel } + } + + private func updateState(_ modifyCallback: (inout SourceEditorState) -> Void) { guard !isUpdatingFromRepresentable else { return } self.isUpdateFromTextView = true - cursorPositions = notificationController.cursorPositions + modifyCallback(&editorState) } deinit { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 7cdd05a75..463bcbe4d 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -12,7 +12,7 @@ import CodeEditLanguages /// A SwiftUI View that provides source editing functionality. public struct SourceEditor: NSViewControllerRepresentable { - package enum TextAPI { + enum TextAPI { case binding(Binding) case storage(NSTextStorage) } @@ -32,7 +32,7 @@ public struct SourceEditor: NSViewControllerRepresentable { _ text: Binding, language: CodeLanguage, configuration: SourceEditorConfiguration, - cursorPositions: Binding<[CursorPosition]>, + state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -40,7 +40,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.text = .binding(text) self.language = language self.configuration = configuration - self.cursorPositions = cursorPositions + self._state = state self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators @@ -61,7 +61,7 @@ public struct SourceEditor: NSViewControllerRepresentable { _ text: NSTextStorage, language: CodeLanguage, configuration: SourceEditorConfiguration, - cursorPositions: Binding<[CursorPosition]>, + state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -69,19 +69,19 @@ public struct SourceEditor: NSViewControllerRepresentable { self.text = .storage(text) self.language = language self.configuration = configuration - self.cursorPositions = cursorPositions + self._state = state self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators } - package var text: TextAPI - private var language: CodeLanguage - private var configuration: SourceEditorConfiguration - package var cursorPositions: Binding<[CursorPosition]> - private var highlightProviders: [any HighlightProviding]? - private var undoManager: CEUndoManager? - package var coordinators: [any TextViewCoordinator] + var text: TextAPI + var language: CodeLanguage + var configuration: SourceEditorConfiguration + @Binding var state: SourceEditorState + var highlightProviders: [any HighlightProviding]? + var undoManager: CEUndoManager? + var coordinators: [any TextViewCoordinator] public typealias NSViewControllerType = TextViewController @@ -90,7 +90,7 @@ public struct SourceEditor: NSViewControllerRepresentable { string: "", language: language, configuration: configuration, - cursorPositions: cursorPositions.wrappedValue, + cursorPositions: state.cursorPositions ?? [], highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, coordinators: coordinators @@ -104,28 +104,28 @@ public struct SourceEditor: NSViewControllerRepresentable { if controller.textView == nil { controller.loadView() } - if !cursorPositions.isEmpty { - controller.setCursorPositions(cursorPositions.wrappedValue) + if !(state.cursorPositions?.isEmpty ?? true) { + controller.setCursorPositions(state.cursorPositions ?? []) } - context.coordinator.controller = controller + context.coordinator.setController(controller) return controller } public func makeCoordinator() -> Coordinator { - Coordinator(text: text, cursorPositions: cursorPositions, highlightProviders: highlightProviders) + Coordinator(text: text, editorState: $state, highlightProviders: highlightProviders) } public func updateNSViewController(_ controller: TextViewController, context: Context) { context.coordinator.updateHighlightProviders(highlightProviders) - if !context.coordinator.isUpdateFromTextView { - // Prevent infinite loop of update notifications + // Prevent infinite loop of update notifications + if context.coordinator.isUpdateFromTextView { + context.coordinator.isUpdateFromTextView = false + } else { context.coordinator.isUpdatingFromRepresentable = true - controller.setCursorPositions(cursorPositions.wrappedValue) + updateControllerWithState(state, controller: controller) context.coordinator.isUpdatingFromRepresentable = false - } else { - context.coordinator.isUpdateFromTextView = false } // Set this no matter what to avoid having to compare object pointers. @@ -147,6 +147,40 @@ public struct SourceEditor: NSViewControllerRepresentable { return } + private func updateControllerWithState(_ state: SourceEditorState, controller: TextViewController) { + if let cursorPositions = state.cursorPositions, cursorPositions != state.cursorPositions { + controller.setCursorPositions(cursorPositions) + } + + if let scrollPosition = state.scrollPosition, scrollPosition != state.scrollPosition { + controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) + controller.scrollView.reflectScrolledClipView(controller.scrollView.contentView) + controller.gutterView.needsDisplay = true + NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: controller.textView) + } + + if let findText = state.findText, findText != controller.findViewController?.viewModel.findText { + controller.findViewController?.viewModel.findText = findText + } + + if let replaceText = state.replaceText, replaceText != controller.findViewController?.viewModel.replaceText { + controller.findViewController?.viewModel.replaceText = replaceText + } + + if let findPanelVisible = state.findPanelVisible, + let findController = controller.findViewController, + findController.viewModel.isShowingFindPanel != findPanelVisible { + // Needs to be on the next runloop, not many great ways to do this besides a dispatch... + DispatchQueue.main.async { + if findPanelVisible { + findController.showFindPanel() + } else { + findController.hideFindPanel() + } + } + } + } + private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { controller.setHighlightProviders(coordinator.highlightProviders) diff --git a/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift b/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift new file mode 100644 index 000000000..933ef9dd2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift @@ -0,0 +1,30 @@ +// +// SourceEditorState.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/19/25. +// + +import AppKit + +public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { + public var cursorPositions: [CursorPosition]? + public var scrollPosition: CGPoint? + public var findText: String? + public var replaceText: String? + public var findPanelVisible: Bool? + + public init( + cursorPositions: [CursorPosition]? = nil, + scrollPosition: CGPoint? = nil, + findText: String? = nil, + replaceText: String? = nil, + findPanelVisible: Bool? = nil + ) { + self.cursorPositions = cursorPositions + self.scrollPosition = scrollPosition + self.findText = findText + self.replaceText = replaceText + self.findPanelVisible = findPanelVisible + } +}