import AppKit import CodeEditLanguages import CodeEditSourceEditor import CodeEditTextView import SwiftUI @MainActor public final class CodeEditEditorController: ObservableObject { weak var controller: TextViewController? @Published public var canUndo = false @Published public var canRedo = false public init() {} public func undo() { controller?.textView.undoManager?.undo() updateUndoState() } public func redo() { controller?.textView.undoManager?.redo() updateUndoState() } public func insertSymbol(_ symbol: String) { guard let textView = controller?.textView else { return } textView.insertText(symbol, replacementRange: textView.selectedRange()) } func updateUndoState() { canUndo = controller?.textView.undoManager?.canUndo ?? false canRedo = controller?.textView.undoManager?.canRedo ?? false } } private extension NSColor { var forEditor: NSColor { usingColorSpace(.sRGB) ?? self } } private func makeTheme(for colorScheme: ColorScheme) -> EditorTheme { var theme: EditorTheme! let build = { theme = EditorTheme( text: .init(color: NSColor.labelColor.forEditor), insertionPoint: NSColor.labelColor.forEditor, invisibles: .init(color: NSColor.tertiaryLabelColor.forEditor), background: NSColor.textBackgroundColor.forEditor, lineHighlight: NSColor.quaternaryLabelColor.forEditor, selection: NSColor.selectedTextBackgroundColor.forEditor, keywords: .init(color: NSColor.systemPurple.forEditor), commands: .init(color: NSColor.systemCyan.forEditor), types: .init(color: NSColor.systemCyan.forEditor), attributes: .init(color: NSColor.systemCyan.forEditor), variables: .init(color: NSColor.labelColor.forEditor), values: .init(color: NSColor.systemOrange.forEditor), numbers: .init(color: NSColor.systemOrange.forEditor), strings: .init(color: NSColor.systemGreen.forEditor), characters: .init(color: NSColor.systemGreen.forEditor), comments: .init(color: NSColor.secondaryLabelColor.forEditor) ) } if let appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) { appearance.performAsCurrentDrawingAppearance(build) } else { build() } return theme } private func makeConfiguration(isEditable: Bool, colorScheme: ColorScheme) -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( theme: makeTheme(for: colorScheme), font: .monospacedSystemFont(ofSize: 14, weight: .regular), lineHeightMultiple: 1.3, wrapLines: false ), behavior: .init( isEditable: isEditable, isSelectable: true ), peripherals: .init( showMinimap: false, showFoldingRibbon: false ) ) } struct CodeEditTextView: NSViewRepresentable { @Binding var text: String let isEditable: Bool let editorController: CodeEditEditorController? @Environment(\.colorScheme) private var colorScheme init(text: Binding, isEditable: Bool, editorController: CodeEditEditorController? = nil) { _text = text self.isEditable = isEditable self.editorController = editorController } func makeNSView(context: Context) -> NSView { let controller = TextViewController( string: text, language: .json, configuration: makeConfiguration(isEditable: isEditable, colorScheme: colorScheme), cursorPositions: [] ) controller.loadView() let containerView = NSView() containerView.translatesAutoresizingMaskIntoConstraints = false let controllerView = controller.view controllerView.translatesAutoresizingMaskIntoConstraints = false containerView.addSubview(controllerView) NSLayoutConstraint.activate([ controllerView.topAnchor.constraint(equalTo: containerView.topAnchor), controllerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), controllerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), controllerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), ]) context.coordinator.controller = controller context.coordinator.lastColorScheme = colorScheme context.coordinator.setupObservation() editorController?.controller = controller Task { @MainActor in editorController?.updateUndoState() } return containerView } func updateNSView(_: NSView, context: Context) { guard let controller = context.coordinator.controller else { return } if controller.text != text { controller.text = text // setText() creates a new Highlighter but doesn't trigger initial highlighting. // Re-setting the language forces the highlighter to invalidate and re-highlight. controller.language = .json } if controller.configuration.behavior.isEditable != isEditable { controller.configuration.behavior.isEditable = isEditable } if context.coordinator.lastColorScheme != colorScheme { context.coordinator.lastColorScheme = colorScheme controller.configuration.appearance.theme = makeTheme(for: colorScheme) } editorController?.controller = controller } func makeCoordinator() -> Coordinator { Coordinator(text: $text, editorController: editorController) } class Coordinator: NSObject { var controller: TextViewController? var lastColorScheme: ColorScheme? @Binding var text: String private var observation: NSObjectProtocol? private weak var editorController: CodeEditEditorController? init(text: Binding, editorController: CodeEditEditorController?) { _text = text self.editorController = editorController super.init() } func setupObservation() { guard let controller else { return } observation = NotificationCenter.default.addObserver( forName: TextView.textDidChangeNotification, object: controller.textView, queue: .main ) { [weak self] _ in guard let self, let controller = self.controller else { return } self.text = controller.text Task { @MainActor in self.editorController?.updateUndoState() } } } deinit { if let observation { NotificationCenter.default.removeObserver(observation) } } } }