aboutsummaryrefslogtreecommitdiff
path: root/Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift')
-rw-r--r--Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift464
1 files changed, 464 insertions, 0 deletions
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
+ }
+ }
+ }
+}