aboutsummaryrefslogtreecommitdiff
path: root/Map/Presentation/Base Components
diff options
context:
space:
mode:
Diffstat (limited to 'Map/Presentation/Base Components')
-rw-r--r--Map/Presentation/Base Components/MapTextEditor.swift284
-rw-r--r--Map/Presentation/Base Components/MapTextEditor/HoverView.swift100
-rw-r--r--Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift464
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
+ }
+ }
+ }
+}