// 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 SwiftTreeSitter import SwiftTreeSitterLayer import SwiftUI import TreeSitterWmap import WmapParser class MapTextEditorController: NSViewController { @Binding var text: String var parsedMap: WmapParser.Map? var highlightRanges: [Range] { didSet { updateHighlights() } } var selectedRange: Int { didSet { updateHighlights() focusOnResult() } } let onChange: () -> Void private let changeDebouncer: Debouncer = Debouncer(seconds: 1) private var trackingArea: NSTrackingArea? private var hoverView: NSView? private var rootLayer: LanguageLayer? var hoverTimer: Timer? var isHoveringValidationText = false var isHoveringHoverView = false init( text: Binding, parsedMap: WmapParser.Map? = nil, 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) if let wmapConfiguration = try? LanguageConfiguration(tree_sitter_wmap(), name: "wmap") { let languageConfiguration = LanguageLayer.Configuration(languageProvider: { name in switch name { case "wmap": return wmapConfiguration default: return nil } }) self.rootLayer = try? LanguageLayer( languageConfig: wmapConfiguration, configuration: languageConfiguration) } } 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.textColor = NSColor.Theme.Syntax.text textView.textContainerInset = NSSize( width: Dimensions.Spacing.regular, height: Dimensions.Spacing.regular) // 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) // Allow text view to grow horizontally textView.maxSize = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) // Enable horizontal scrollbar when soft wrap is disabled scrollView.hasHorizontalScroller = true scrollView.horizontalScrollElasticity = .none } else { textView.textContainer?.widthTracksTextView = true // Reset max size for wrapping textView.maxSize = NSSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) // Hide horizontal scrollbar when soft wrap is enabled scrollView.hasHorizontalScroller = false scrollView.horizontalScrollElasticity = .automatic } textView.isHorizontallyResizable = true textView.isVerticallyResizable = true setupTrackingArea(for: textView) rootLayer?.replaceContent(with: text) 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 scrollToCursor() { if let textView = currentTextView { let cursorRange = textView.selectedRange() textView.scrollRangeToVisible(cursorRange) } } 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 { NSCursor.arrow.set() 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 missingVertex = attributes[.init(rawValue: "MissingComponent")] as? String { isHoveringValidationText = true hoverTimer?.invalidate() showHoverView(for: effectiveRange, with: missingVertex, 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 applyTreeSitterHighlighting(textStorage: NSTextStorage, range: NSRange) { guard let highlights = try? rootLayer?.highlights(in: range, provider: text.predicateTextProvider) else { return } for highlight in highlights { let color = NSColor.Theme.Syntax.colorForSyntax(highlight.name) var attributes: [NSAttributedString.Key: Any] = [ .foregroundColor: color ] // Check for missing components if UserDefaults.standard.bool(forKey: "useSmartEditor"), let parsedMap, highlight.name == "variable.component_label" { // Extract the component label text from the range let componentText = (textStorage.string as NSString).substring(with: highlight.range) .trimmingCharacters(in: .whitespacesAndNewlines) // Check if this component exists in the parsed map let componentExists = parsedMap.components.contains(where: { $0.label.lowercased() == componentText.lowercased() }) if !componentExists && UserDefaults.standard.bool(forKey: "highlightMissingComponents") { attributes[.underlineStyle] = NSUnderlineStyle.thick.rawValue | NSUnderlineStyle.patternDot.rawValue attributes[.underlineColor] = NSColor.Theme.Syntax.error attributes[.init("MissingComponent")] = componentText } } textStorage.addAttributes(attributes, range: highlight.range) } } } extension MapTextEditorController: NSTextViewDelegate { func textDidChange(_ obj: Notification) { if let textField = obj.object as? NSTextView { self.text = textField.string // Auto-scroll to keep cursor visible when typing scrollToCursor() 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 let content = textStorage.string // Update tree-sitter layer with new content Task { @MainActor in rootLayer?.replaceContent(with: content) } 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) colorizeTextRange(textStorage: textStorage, range: lineRange) } private func colorizeText(textStorage: NSTextStorage) { let range = NSRange(location: 0, length: textStorage.length) colorizeTextRange(textStorage: textStorage, range: range) } private func colorizeTextRange(textStorage: NSTextStorage, range: NSRange) { textStorage.removeAttribute(.foregroundColor, range: range) textStorage.removeAttribute(.underlineStyle, range: range) textStorage.removeAttribute(.underlineColor, range: range) // Set default text color for the range textStorage.addAttribute(.foregroundColor, value: NSColor.Theme.Syntax.text, range: range) // Apply tree-sitter syntax highlighting applyTreeSitterHighlighting(textStorage: textStorage, range: range) } } struct MapTextEditor: NSViewControllerRepresentable { @Binding var text: String var parsedMap: WmapParser.Map? var highlightRanges: [Range] var selectedRange: Int var onChange: () -> Void = {} init( text: Binding, parsedMap: WmapParser.Map? = nil, 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 ) { 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 } } } }