// Copyright (C) 2024 Rubén Beltrán del Río // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with this program. If not, see https://map.tranquil.systems. import Cocoa import SwiftUI class MapTextEditorController: NSViewController { @Binding var text: String var parsedMap: [Wmap.Entity] = [] { didSet { syntaxHighlighter.updateSyntaxElements(from: parsedMap) // Don't re-highlight entire document here - let the text change handler do it } } var highlightRanges: [Range] { didSet { updateHighlights() } } var selectedRange: Int { didSet { updateHighlights() focusOnResult() } } let onChange: () -> Void private let syntaxHighlighter = Wmap.SyntaxHighlighter() private let changeDebouncer: Debouncer = Debouncer(seconds: 1) init( text: Binding, parsedMap: [Wmap.Entity] = [], highlightRanges: [Range], selectedRange: Int, onChange: @escaping () -> Void ) { self._text = text self.parsedMap = parsedMap self.onChange = onChange self.highlightRanges = highlightRanges self.selectedRange = selectedRange super.init(nibName: nil, bundle: nil) Task { syntaxHighlighter.updateSyntaxElements(from: parsedMap) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let scrollView = NSTextView.scrollableTextView() let textView = scrollView.documentView as! NSTextView scrollView.translatesAutoresizingMaskIntoConstraints = false textView.backgroundColor = .Theme.UI.background textView.allowsUndo = true textView.delegate = self textView.textStorage?.delegate = self textView.string = self.text textView.isEditable = true textView.font = NSFont.Theme.Editor.regular textView.textContainerInset = NSSize(width: 0, height: Dimensions.Spacing.coziest) // Disable word wrapping to prevent flashing on soft-wrapped lines textView.textContainer?.widthTracksTextView = false textView.textContainer?.containerSize = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isHorizontallyResizable = true textView.isVerticallyResizable = true self.view = scrollView } override func viewDidAppear() { self.view.window?.makeFirstResponder(self.view) updateHighlights() // Initial colorization of the entire document if let textView = currentTextView, let textStorage = textView.textStorage { colorizeText(textStorage: textStorage) } } var currentTextView: NSTextView? { return (view as? NSScrollView)?.documentView as? NSTextView } private func updateHighlights() { if let textView = currentTextView { if let textStorage = textView.textStorage { textStorage.removeAttribute( .backgroundColor, range: NSRange(location: 0, length: textStorage.length)) for (index, range) in highlightRanges.enumerated() { let nsRange = NSRange(range, in: textStorage.string) let color = index == selectedRange ? NSColor.Theme.Syntax.highlightMatch : NSColor.Theme.Syntax.match textStorage.addAttribute(.backgroundColor, value: color, range: nsRange) } textView.needsDisplay = true } } } private func focusOnResult() { if let textView = currentTextView { if let textStorage = textView.textStorage { if selectedRange < highlightRanges.count { let range = highlightRanges[selectedRange] let nsRange = NSRange(range, in: textStorage.string) textView.scrollRangeToVisible(nsRange) } } } } } extension MapTextEditorController: NSTextViewDelegate { func textDidChange(_ obj: Notification) { if let textField = obj.object as? NSTextView { self.text = textField.string changeDebouncer.debounce { DispatchQueue.main.async { self.onChange() } } } } func textView(_ view: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool { let range = Range(shouldChangeTextIn, in: view.string) let target = view.string[range!] if target == "--" { return false } return true } } extension MapTextEditorController: NSTextStorageDelegate { override func textStorageDidProcessEditing(_ obj: Notification) { if let textStorage = obj.object as? NSTextStorage { // Only process if this is a text content change, not an attribute change guard textStorage.editedMask.contains(.editedCharacters) else { return } let editedRange = textStorage.editedRange if editedRange.location != NSNotFound { // Use a small delay to batch rapid changes and avoid flashing DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self = self, let currentTextView = self.currentTextView, let currentTextStorage = currentTextView.textStorage else { return } self.colorizeEditedText(textStorage: currentTextStorage, editedRange: editedRange) } } } } private func colorizeEditedText(textStorage: NSTextStorage, editedRange: NSRange) { // Expand range to include the entire line(s) that were edited let string = textStorage.string as NSString let lineRange = string.lineRange(for: editedRange) // Clear existing syntax highlighting attributes in the range, but preserve background colors textStorage.removeAttribute(.foregroundColor, range: lineRange) // Apply syntax highlighting syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: lineRange) } private func colorizeText(textStorage: NSTextStorage) { let range = NSMakeRange(0, textStorage.length) // Clear all existing attributes textStorage.removeAttribute(.foregroundColor, range: range) syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: range) } } struct MapTextEditor: NSViewControllerRepresentable { @Binding var text: String var parsedMap: [Wmap.Entity] = [] var highlightRanges: [Range] var selectedRange: Int var onChange: () -> Void = {} init( text: Binding, parsedMap: [Wmap.Entity] = [], highlightRanges: [Range] = [], selectedRange: Int = 0, onChange: @escaping () -> Void = {} ) { self._text = text self.parsedMap = parsedMap self.highlightRanges = highlightRanges self.selectedRange = selectedRange self.onChange = onChange } func makeNSViewController( context: NSViewControllerRepresentableContext ) -> MapTextEditorController { return MapTextEditorController( text: $text, parsedMap: parsedMap, highlightRanges: highlightRanges, selectedRange: selectedRange, onChange: onChange) } func updateNSViewController( _ nsViewController: MapTextEditorController, context: NSViewControllerRepresentableContext ) { // Only update if something actually changed to prevent excessive updates var hasChanges = false // Always update parsed map since the content may have changed even if count is same nsViewController.parsedMap = parsedMap if nsViewController.highlightRanges != highlightRanges { nsViewController.highlightRanges = highlightRanges hasChanges = true } if nsViewController.selectedRange != selectedRange { nsViewController.selectedRange = selectedRange hasChanges = true } // Update text view content if text has changed if let textView = nsViewController.currentTextView { if textView.string != text { textView.string = text hasChanges = true } } // Only log if we made changes (for debugging) if hasChanges { print("MapTextEditor: Updated view controller") } } }