diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-07-11 09:27:49 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-07-11 09:27:49 +0200 |
| commit | 8564439356f4bcc3e21b19738eb87b4c0722c623 (patch) | |
| tree | 1465f4e48be83d255e5c68de804ba940db238bea /Map | |
| parent | ff3619fdf3fce82d95124654a18b66ca3ed380cd (diff) | |
Add missing vertex detection
Diffstat (limited to 'Map')
| -rw-r--r-- | Map/Business/WmapParser/Parser.swift | 17 | ||||
| -rw-r--r-- | Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift | 25 | ||||
| -rw-r--r-- | Map/Data/MapDocument.swift | 4 | ||||
| -rw-r--r-- | Map/Data/UserPreferences.swift | 14 | ||||
| -rw-r--r-- | Map/Localizable.xcstrings | 33 | ||||
| -rw-r--r-- | Map/Presentation/Base Components/MapTextEditor.swift | 284 | ||||
| -rw-r--r-- | Map/Presentation/Base Components/MapTextEditor/HoverView.swift | 100 | ||||
| -rw-r--r-- | Map/Presentation/Base Components/MapTextEditor/MapTextEditor.swift | 464 | ||||
| -rw-r--r-- | Map/Presentation/MapEditor.swift | 14 | ||||
| -rw-r--r-- | Map/Presentation/Preferences/EditorPreferencesView.swift | 8 | ||||
| -rw-r--r-- | Map/Presentation/Theme/NSColor+theme.swift | 1 | ||||
| -rw-r--r-- | Map/Presentation/Theme/NSFont+theme.swift | 31 |
12 files changed, 698 insertions, 297 deletions
diff --git a/Map/Business/WmapParser/Parser.swift b/Map/Business/WmapParser/Parser.swift index 9418263..86c36ef 100644 --- a/Map/Business/WmapParser/Parser.swift +++ b/Map/Business/WmapParser/Parser.swift @@ -15,6 +15,12 @@ import Foundation extension Wmap { + + struct ParsedMap { + let entities: [Entity] + let vertexLabels: Set<String> + } + class Parser { private let lexer: Lexer private var currentToken: Token @@ -25,8 +31,9 @@ extension Wmap { self.currentToken = lexer.nextToken() } - func parse() -> [Entity] { + func parse() -> ParsedMap { var entities: [Entity] = [] + var vertexLabels: Set<String> = Set() while currentToken.type != .eof { if currentToken.type == .newline { @@ -37,6 +44,12 @@ extension Wmap { do { let entity = try parseEntity() entities.append(entity) + switch entity { + case .vertex(let vertex): + vertexLabels.insert(vertex.label) + default: + break + } if currentToken.type == .newline { advance() @@ -48,7 +61,7 @@ extension Wmap { } } - return entities + return ParsedMap(entities: entities, vertexLabels: vertexLabels) } private func parseEntity() throws -> Entity { diff --git a/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift b/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift index e0aaee7..d51a80f 100644 --- a/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift +++ b/Map/Business/WmapSyntaxHighlighter/SyntaxHighlighter.swift @@ -16,11 +16,13 @@ import AppKit extension Wmap { class SyntaxHighlighter { - private var cachedSyntaxElements: [SyntaxElement] = [] + var cachedSyntaxElements: [SyntaxElement] = [] private var lineToElementsMap: [Int: [SyntaxElement]] = [:] + private var cachedVertexLabels: Set<String> = Set() - func updateSyntaxElements(from parsedMap: [Entity]) { - cachedSyntaxElements = parsedMap.flatMap { $0.syntaxElements } + func updateSyntaxElements(from parsedMap: Wmap.ParsedMap) { + cachedSyntaxElements = parsedMap.entities.flatMap { $0.syntaxElements } + cachedVertexLabels = parsedMap.vertexLabels // Build line-to-elements mapping for fast lookup lineToElementsMap.removeAll() @@ -55,7 +57,22 @@ extension Wmap { } let color = colorForSyntaxType(element.type) - textStorage.addAttribute(.foregroundColor, value: color, range: nsRange) + var attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: color + ] + + if UserDefaults.standard.bool(forKey: "useSmartEditor") { + if element.type == .vertexLabel && !cachedVertexLabels.contains(element.value) { + if UserDefaults.standard.bool(forKey: "highlightMissingComponents") { + attributes[.underlineStyle] = + NSUnderlineStyle.thick.rawValue | NSUnderlineStyle.patternDot.rawValue + attributes[.underlineColor] = NSColor.Theme.Syntax.error + attributes[.init("MissingComponent")] = element.value + } + } + } + + textStorage.addAttributes(attributes, range: nsRange) } } diff --git a/Map/Data/MapDocument.swift b/Map/Data/MapDocument.swift index 6cc84d7..b9ed33f 100644 --- a/Map/Data/MapDocument.swift +++ b/Map/Data/MapDocument.swift @@ -46,7 +46,7 @@ struct MapDocument: FileDocument { static var readableContentTypes: [UTType] { [.wmap] } - var parsed: [Wmap.Entity] { + var parsed: Wmap.ParsedMap { let lexer = Wmap.Lexer(self.text) let parser = Wmap.Parser(lexer: lexer) return parser.parse() @@ -69,7 +69,7 @@ struct MapDocument: FileDocument { @MainActor func exportAsImage(withEvolution selectedEvolution: StageType) -> NSImage? { let renderView = MapRenderView( - entities: self.parsed, + entities: self.parsed.entities, evolution: .constant(selectedEvolution)) let renderer = ImageRenderer(content: renderView) diff --git a/Map/Data/UserPreferences.swift b/Map/Data/UserPreferences.swift index 12f83c5..56582e3 100644 --- a/Map/Data/UserPreferences.swift +++ b/Map/Data/UserPreferences.swift @@ -30,6 +30,7 @@ struct UserPreferencesJSON: Codable { var customEditorFontName: String? var editorFontSize: Double? var softWrapLines: Bool? + var highlightMissingComponents: Bool? // MARK: - Templates (as actual objects) var mapTemplates: [Template]? @@ -57,6 +58,7 @@ struct UserPreferences: Codable { var customEditorFontName: String var editorFontSize: Double var softWrapLines: Bool + var highlightMissingComponents: Bool // MARK: - Templates var mapTemplates: Data @@ -76,11 +78,12 @@ struct UserPreferences: Codable { static let defaultExportFormat = "png" static let useSmartLabelPositioning = true - static let useSmartEditor = false + static let useSmartEditor = true static let useCustomEditorFont = false static let customEditorFontName = "Menlo" static let editorFontSize = 14.0 static let softWrapLines = false + static let highlightMissingComponents = true static let viewStyle = "horizontal" static let zoom = 1.0 @@ -123,6 +126,7 @@ struct UserPreferences: Codable { self.customEditorFontName = Defaults.customEditorFontName self.editorFontSize = Defaults.editorFontSize self.softWrapLines = Defaults.softWrapLines + self.highlightMissingComponents = Defaults.highlightMissingComponents self.mapTemplates = Defaults.mapTemplates self.customStages = Defaults.customStages @@ -149,6 +153,7 @@ struct UserPreferences: Codable { customEditorFontName: customEditorFontName, editorFontSize: editorFontSize, softWrapLines: softWrapLines, + highlightMissingComponents: highlightMissingComponents, mapTemplates: templatesFromData(mapTemplates), customStages: stagesFromData(customStages), viewStyle: viewStyle, @@ -200,6 +205,9 @@ struct UserPreferences: Codable { if let value = jsonPrefs.softWrapLines { self.softWrapLines = value } + if let value = jsonPrefs.highlightMissingComponents { + self.highlightMissingComponents = value + } if let value = jsonPrefs.mapTemplates { self.mapTemplates = dataFromTemplates(value) } @@ -273,6 +281,7 @@ struct UserPreferences: Codable { defaults.set(customEditorFontName, forKey: "customEditorFontName") defaults.set(editorFontSize, forKey: "editorFontSize") defaults.set(softWrapLines, forKey: "softWrapLines") + defaults.set(highlightMissingComponents, forKey: "highlightMissingComponents") // Templates defaults.set(mapTemplates, forKey: "mapTemplates") @@ -315,6 +324,9 @@ struct UserPreferences: Codable { defaults.object(forKey: "editorFontSize") as? Double ?? Defaults.editorFontSize preferences.softWrapLines = defaults.object(forKey: "softWrapLines") as? Bool ?? Defaults.softWrapLines + preferences.highlightMissingComponents = + defaults.object(forKey: "highlightMissingComponents") as? Bool + ?? Defaults.highlightMissingComponents // Templates preferences.mapTemplates = diff --git a/Map/Localizable.xcstrings b/Map/Localizable.xcstrings index d5da2e4..ec8ed46 100644 --- a/Map/Localizable.xcstrings +++ b/Map/Localizable.xcstrings @@ -219,6 +219,28 @@ } } }, + "map_editor.add_missing_component" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "map_editor.missing_component %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@” is not declared." + } + } + } + }, "map_editor.zoom_in" : { "extractionState" : "manual", "localizations" : { @@ -383,6 +405,17 @@ } } }, + "preferences.editor.smart_editor.highlight_missing_components" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Highlight missing components" + } + } + } + }, "preferences.editor.smart_editor.title" : { "extractionState" : "manual", "localizations" : { 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 + } + } + } +} diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift index c8ce5cd..f9a8dae 100644 --- a/Map/Presentation/MapEditor.swift +++ b/Map/Presentation/MapEditor.swift @@ -19,7 +19,7 @@ struct MapEditor: View { var url: URL? @State var selectedEvolution: StageType = .behavior @State var isSearching: Bool = false - var parsedMap: [Wmap.Entity] { + var parsedMap: Wmap.ParsedMap { document.parsed } @@ -34,6 +34,11 @@ struct MapEditor: View { @AppStorage("editorFontSize") var editorFontSize: Double = UserPreferences.Defaults.editorFontSize @AppStorage("softWrapLines") var softWrapLines: Bool = UserPreferences.Defaults .softWrapLines + @AppStorage("useSmartEditor") var useSmartEditor: Bool = UserPreferences.Defaults + .useSmartEditor + @AppStorage("highlightMissingComponents") var highlightMissingComponents: Bool = UserPreferences + .Defaults + .highlightMissingComponents let zoomRange = Constants.kMinZoom...Constants.kMaxZoom @AppStorage("zoom") var zoom = UserPreferences.Defaults.zoom @@ -112,7 +117,9 @@ struct MapEditor: View { highlightRanges: results, selectedRange: selectedTerm ) - .id("\(useCustomEditorFont)-\(customEditorFontName)-\(editorFontSize)-\(softWrapLines)") + .id( + "\(useCustomEditorFont)-\(customEditorFontName)-\(editorFontSize)-\(softWrapLines)-\(useSmartEditor)-\(highlightMissingComponents)" + ) .background(Color.Theme.UI.background) .foregroundColor(Color.Theme.UI.foreground) .frame(minHeight: 96.0) @@ -122,7 +129,8 @@ struct MapEditor: View { GeometryReader { geometry in ScrollView([.horizontal, .vertical], showsIndicators: false) { MapRenderView( - entities: parsedMap, evolution: $selectedEvolution, onDragVertex: onDragVertex + entities: parsedMap.entities, evolution: $selectedEvolution, + onDragVertex: onDragVertex ) .scaleEffect(zoom, anchor: .center).frame( width: (Dimensions.Map.size.width + 2 * Dimensions.Map.padding) * zoom, diff --git a/Map/Presentation/Preferences/EditorPreferencesView.swift b/Map/Presentation/Preferences/EditorPreferencesView.swift index c08b03e..98e96f0 100644 --- a/Map/Presentation/Preferences/EditorPreferencesView.swift +++ b/Map/Presentation/Preferences/EditorPreferencesView.swift @@ -26,6 +26,9 @@ struct EditorPreferencesView: View { .editorFontSize @AppStorage("softWrapLines") private var softWrapLines: Bool = UserPreferences.Defaults .softWrapLines + @AppStorage("highlightMissingComponents") private var highlightMissingComponents: Bool = + UserPreferences.Defaults + .softWrapLines var monospacedFonts: [String] { let fontManager = NSFontManager.shared @@ -117,6 +120,11 @@ struct EditorPreferencesView: View { VStack(alignment: .leading, spacing: Dimensions.Spacing.cozy) { Toggle( String(localized: "preferences.editor.smart_editor.enabled"), isOn: $useSmartEditor) + Toggle( + String(localized: "preferences.editor.smart_editor.highlight_missing_components"), + isOn: $highlightMissingComponents + ) + .disabled(!useSmartEditor) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Map/Presentation/Theme/NSColor+theme.swift b/Map/Presentation/Theme/NSColor+theme.swift index e10f51e..b2ff106 100644 --- a/Map/Presentation/Theme/NSColor+theme.swift +++ b/Map/Presentation/Theme/NSColor+theme.swift @@ -28,6 +28,7 @@ extension NSColor { static let highlightMatch = (NSColor(named: "Naples Yellow") ?? .textColor) .withAlphaComponent( 0.3) + static let error = (NSColor(named: "Jasper Red") ?? .textColor) } struct UI { diff --git a/Map/Presentation/Theme/NSFont+theme.swift b/Map/Presentation/Theme/NSFont+theme.swift index 5d8d8dd..cd119f2 100644 --- a/Map/Presentation/Theme/NSFont+theme.swift +++ b/Map/Presentation/Theme/NSFont+theme.swift @@ -15,9 +15,38 @@ import AppKit extension NSFont { + + static func libertinus(size: CGFloat) -> NSFont { + return NSFont(name: "Libertinus Serif", size: size) ?? .systemFont(ofSize: size) + } + + static func ronzino(size: CGFloat) -> NSFont { + return NSFont(name: "Ronzino", size: size) ?? .systemFont(ofSize: size) + } + public struct Theme { + + struct Body { + @MainActor static let regular = NSFont.ronzino(size: Dimensions.FontSize.body) + @MainActor static let emphasized = NSFont.ronzino(size: Dimensions.FontSize.body) + } + + struct Title { + @MainActor static let emphasized = NSFont.libertinus(size: Dimensions.FontSize.title) + } + + struct Caption { + @MainActor static let regular = NSFont.ronzino(size: Dimensions.FontSize.caption) + @MainActor static let emphasized = NSFont.ronzino(size: Dimensions.FontSize.caption) + } + + struct SmallControl { + @MainActor static let regular = NSFont.ronzino(size: Dimensions.FontSize.smallControl) + @MainActor static let emphasized = NSFont.ronzino(size: Dimensions.FontSize.smallControl) + } + struct Editor { - static var regular: NSFont { + @MainActor static var regular: NSFont { if UserDefaults.standard.bool(forKey: "useCustomEditorFont"), let customEditorFontName = UserDefaults.standard.string(forKey: "customEditorFontName"), !customEditorFontName.isEmpty |