diff options
Diffstat (limited to 'Map/Presentation/Base Components')
3 files changed, 564 insertions, 284 deletions
diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift deleted file mode 100644 index 4295918..0000000 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ /dev/null @@ -1,284 +0,0 @@ -// 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<String.Index>] { - 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<String>, - parsedMap: [Wmap.Entity] = [], - highlightRanges: [Range<String.Index>], - 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) - - // Configure text wrapping based on user preference - let softWrapLines = UserDefaults.standard.bool(forKey: "softWrapLines") - if !softWrapLines { - // Disable word wrapping to prevent flashing on soft-wrapped lines - textView.textContainer?.widthTracksTextView = false - textView.textContainer?.containerSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - } else { - textView.textContainer?.widthTracksTextView = true - } - - 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<String.Index>] - var selectedRange: Int - var onChange: () -> Void = {} - - init( - text: Binding<String>, parsedMap: [Wmap.Entity] = [], - highlightRanges: [Range<String.Index>] = [], 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<MapTextEditor> - ) -> MapTextEditorController { - return MapTextEditorController( - text: $text, parsedMap: parsedMap, highlightRanges: highlightRanges, - selectedRange: selectedRange, - onChange: onChange) - } - - func updateNSViewController( - _ nsViewController: MapTextEditorController, - context: NSViewControllerRepresentableContext<MapTextEditor> - ) { - // 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") - } - } -} diff --git a/Map/Presentation/Base Components/MapTextEditor/HoverView.swift b/Map/Presentation/Base Components/MapTextEditor/HoverView.swift new file mode 100644 index 0000000..4e42b99 --- /dev/null +++ b/Map/Presentation/Base Components/MapTextEditor/HoverView.swift @@ -0,0 +1,100 @@ +// 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 AppKit + +class HoverView: NSView { + weak var controller: MapTextEditorController? + private let action: () -> Void + + init( + message: String, + action: @escaping () -> Void, + actionLabel: String = String(localized: "map_editor.add_missing_component"), + controller: MapTextEditorController + ) { + self.controller = controller + self.action = action + super.init(frame: .zero) + + setupView(message: message, actionLabel: actionLabel) + setupTrackingArea() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView(message: String, actionLabel: String) { + wantsLayer = true + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.borderWidth = 1 + layer?.cornerRadius = 8 + + let label = NSTextField(labelWithString: message) + label.font = NSFont.Theme.SmallControl.regular + label.textColor = NSColor.labelColor + + let button = NSButton(title: actionLabel, target: self, action: #selector(buttonClicked)) + button.bezelStyle = .accessoryBarAction + button.font = NSFont.Theme.SmallControl.emphasized + button.controlSize = .mini + + addSubview(label) + addSubview(button) + + label.translatesAutoresizingMaskIntoConstraints = false + button.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: topAnchor, constant: 8), + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + + button.topAnchor.constraint(equalTo: topAnchor, constant: 8), + button.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8), + button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + button.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + + heightAnchor.constraint(equalToConstant: 32), + ]) + } + + private func setupTrackingArea() { + let trackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + } + + @objc private func buttonClicked() { + action() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + controller?.isHoveringHoverView = true + controller?.hoverTimer?.invalidate() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + controller?.isHoveringHoverView = false + controller?.scheduleHideHoverView() + } +} diff --git a/Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift new file mode 100644 index 0000000..6a1be83 --- /dev/null +++ b/Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift @@ -0,0 +1,464 @@ +// 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.ParsedMap { + didSet { + syntaxHighlighter.updateSyntaxElements(from: parsedMap) + // Don't re-highlight entire document here - let the text change handler do it + } + } + + var highlightRanges: [Range<String.Index>] { + didSet { + updateHighlights() + } + } + + var selectedRange: Int { + didSet { + updateHighlights() + focusOnResult() + } + } + + let onChange: () -> Void + + private let syntaxHighlighter = Wmap.SyntaxHighlighter() + private let changeDebouncer: Debouncer = Debouncer(seconds: 1) + private var trackingArea: NSTrackingArea? + private var hoverView: NSView? + var hoverTimer: Timer? + var isHoveringValidationText = false + var isHoveringHoverView = false + + init( + text: Binding<String>, + parsedMap: Wmap.ParsedMap = Wmap.ParsedMap(entities: [], vertexLabels: Set()), + highlightRanges: [Range<String.Index>], + 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: Dimensions.Spacing.coziest, height: Dimensions.Spacing.coziest) + + // Configure text wrapping based on user preference + let softWrapLines = UserDefaults.standard.bool(forKey: "softWrapLines") + if !softWrapLines { + // Disable word wrapping to prevent flashing on soft-wrapped lines + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + } else { + textView.textContainer?.widthTracksTextView = true + } + + textView.isHorizontallyResizable = true + textView.isVerticallyResizable = true + + setupTrackingArea(for: textView) + 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 { + let fullRange = NSRange(location: 0, length: textStorage.length) + textStorage.removeAttribute( + .backgroundColor, range: fullRange) + if #available(macOS 15.0, *) { + textStorage.removeAttribute(.textHighlightStyle, range: fullRange) + textStorage.removeAttribute(.textHighlightColorScheme, range: fullRange) + } + + for (index, range) in highlightRanges.enumerated() { + let nsRange = NSRange(range, in: textStorage.string) + + if #available(macOS 15.0, *) { + let color = + index == selectedRange + ? NSAttributedString.TextHighlightColorScheme.blue + : NSAttributedString.TextHighlightColorScheme.mint + textStorage.addAttribute( + .textHighlightStyle, value: NSAttributedString.TextHighlightStyle.default, + range: nsRange) + textStorage.addAttribute(.textHighlightColorScheme, value: color, range: nsRange) + } else { + 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) + } + } + } + } + + private func setupTrackingArea(for textView: NSTextView) { + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow] + trackingArea = NSTrackingArea( + rect: textView.bounds, options: options, owner: self, userInfo: nil) + textView.addTrackingArea(trackingArea!) + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + if event.trackingArea?.userInfo?["isHoverView"] != nil { + isHoveringHoverView = true + hoverTimer?.invalidate() + } else { + handleMouseEvent(event) + } + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + handleMouseEvent(event) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + if event.trackingArea?.userInfo?["isHoverView"] != nil { + isHoveringHoverView = false + scheduleHideHoverView() + } else { + isHoveringValidationText = false + scheduleHideHoverView() + } + } + + private func handleMouseEvent(_ event: NSEvent) { + // Don't handle mouse events if we're already hovering over the hover view + if isHoveringHoverView { + return + } + + guard let textView = currentTextView, + let textStorage = textView.textStorage + else { return } + + let locationInTextView = textView.convert(event.locationInWindow, from: nil) + let characterIndex = textView.characterIndexForInsertion(at: locationInTextView) + + guard characterIndex < textStorage.length else { + isHoveringValidationText = false + scheduleHideHoverView() + return + } + + var effectiveRange = NSRange() + let attributes = textStorage.attributes(at: characterIndex, effectiveRange: &effectiveRange) + if let missinVertex = attributes[.init(rawValue: "MissingComponent")] as? String { + isHoveringValidationText = true + hoverTimer?.invalidate() + showHoverView(for: effectiveRange, with: missinVertex, characterIndex: characterIndex) + } else { + isHoveringValidationText = false + scheduleHideHoverView() + } + } + + private func showHoverView(for range: NSRange, with missingVertex: String, characterIndex: Int) { + hideHoverView() + + guard let textView = currentTextView, + let textContainer = textView.textContainer, + let layoutManager = textView.layoutManager + else { return } + + // Get the glyph range for the text range + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + + // Get the bounding rect for the range + let boundingRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // Position the hover view below the text range, at the beginning of the range + let hoverX = boundingRect.minX + textView.textContainerInset.width + let hoverY = boundingRect.maxY + textView.textContainerInset.height + 5 // 5pt below the text + + let hoverView = HoverView( + message: String(localized: "map_editor.missing_component \(missingVertex)"), + action: { [weak self] in + self?.insertMissingVertex(missingVertex, at: characterIndex) + }, + controller: self + ) + + textView.addSubview(hoverView) + hoverView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hoverView.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: hoverX), + hoverView.topAnchor.constraint(equalTo: textView.topAnchor, constant: hoverY), + ]) + + self.hoverView = hoverView + } + + private func hideHoverView() { + hoverTimer?.invalidate() + hoverView?.removeFromSuperview() + hoverView = nil + isHoveringValidationText = false + isHoveringHoverView = false + } + + func scheduleHideHoverView() { + hoverTimer?.invalidate() + hoverTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + // Only hide if we're not hovering over either the validation text or the hover view + if !self.isHoveringValidationText && !self.isHoveringHoverView { + self.hideHoverView() + } + } + } + } + + private func insertMissingVertex(_ missingVertex: String, at characterIndex: Int) { + guard let textView = currentTextView, + let textStorage = textView.textStorage + else { return } + + let insertText = "\(missingVertex) (50, 50)" + + // Find the line containing the character index where the missing vertex was detected + let string = textStorage.string as NSString + let lineRange = string.lineRange(for: NSRange(location: characterIndex, length: 0)) + + // Insert at the beginning of the current line + let insertLocation = lineRange.location + + textStorage.replaceCharacters( + in: NSRange(location: insertLocation, length: 0), with: "\(insertText)\n") + + // Update the binding to trigger text change + self.text = textStorage.string + + // Move cursor after the inserted text + textView.setSelectedRange(NSRange(location: insertLocation + insertText.count + 1, length: 0)) + hideHoverView() + + // Trigger onChange to update the parsed map and syntax highlighting + onChange() + + // Force immediate re-highlighting of the entire document to update broken references + DispatchQueue.main.async { [weak self] in + guard let self = self, + let textView = self.currentTextView, + let textStorage = textView.textStorage + else { return } + self.colorizeText(textStorage: textStorage) + } + } + + private func getSyntaxElementAt(characterIndex: Int) -> Wmap.SyntaxElement? { + let textStorage = currentTextView?.textStorage + guard let textStorage = textStorage else { return nil } + + for element in syntaxHighlighter.cachedSyntaxElements { + let nsRange = NSRange(element.range.stringRange, in: textStorage.string) + if nsRange.contains(characterIndex) { + return element + } + } + return nil + } +} + +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 { + 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) { + let string = textStorage.string as NSString + let lineRange = string.lineRange(for: editedRange) + + textStorage.removeAttribute(.foregroundColor, range: lineRange) + textStorage.removeAttribute(.underlineStyle, range: lineRange) + textStorage.removeAttribute(.underlineColor, range: lineRange) + syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: lineRange) + } + + private func colorizeText(textStorage: NSTextStorage) { + let range = NSMakeRange(0, textStorage.length) + + textStorage.removeAttribute(.foregroundColor, range: range) + textStorage.removeAttribute(.underlineStyle, range: range) + textStorage.removeAttribute(.underlineColor, range: range) + syntaxHighlighter.applySyntaxHighlighting(textStorage: textStorage, range: range) + } +} + +struct MapTextEditor: NSViewControllerRepresentable { + + @Binding var text: String + var parsedMap: Wmap.ParsedMap + var highlightRanges: [Range<String.Index>] + var selectedRange: Int + var onChange: () -> Void = {} + + init( + text: Binding<String>, + parsedMap: Wmap.ParsedMap = Wmap.ParsedMap(entities: [], vertexLabels: Set()), + highlightRanges: [Range<String.Index>] = [], 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<MapTextEditor> + ) -> MapTextEditorController { + return MapTextEditorController( + text: $text, parsedMap: parsedMap, highlightRanges: highlightRanges, + selectedRange: selectedRange, + onChange: onChange) + } + + func updateNSViewController( + _ nsViewController: MapTextEditorController, + context: NSViewControllerRepresentableContext<MapTextEditor> + ) { + nsViewController.parsedMap = parsedMap + + if nsViewController.highlightRanges != highlightRanges { + nsViewController.highlightRanges = highlightRanges + } + + if nsViewController.selectedRange != selectedRange { + nsViewController.selectedRange = selectedRange + } + + if let textView = nsViewController.currentTextView { + if textView.string != text { + textView.string = text + } + } + } +} |