]> git.r.bdr.sh - rbdr/map/blob - Map/Presentation/Base Components/MapTextEditor.swift
2e4f279cd3d6ea0266a8d646479b31a1284f6601
[rbdr/map] / Map / Presentation / Base Components / MapTextEditor.swift
1 // Copyright (C) 2024 Rubén Beltrán del Río
2
3 // This program is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
12
13 // You should have received a copy of the GNU General Public License
14 // along with this program. If not, see https://map.tranquil.systems.
15 import Cocoa
16 import SwiftUI
17
18 class MapTextEditorController: NSViewController {
19
20 @Binding var document: MapDocument
21 var highlightRanges: [Range<String.Index>] {
22 didSet {
23 updateHighlights()
24 }
25 }
26
27 var selectedRange: Int {
28 didSet {
29 updateHighlights()
30 focusOnResult()
31 }
32 }
33
34 let onChange: () -> Void
35
36 private let vertexRegex = MapParsingPatterns.vertex
37 private let edgeRegex = MapParsingPatterns.edge
38 private let blockerRegex = MapParsingPatterns.blocker
39 private let opportunityRegex = MapParsingPatterns.opportunity
40 private let noteRegex = MapParsingPatterns.note
41 private let stageRegex = MapParsingPatterns.stage
42 private let groupRegex = MapParsingPatterns.group
43
44 private let changeDebouncer: Debouncer = Debouncer(seconds: 1)
45
46 init(
47 document: Binding<MapDocument>, highlightRanges: [Range<String.Index>], selectedRange: Int,
48 onChange: @escaping () -> Void
49 ) {
50 self._document = document
51 self.onChange = onChange
52 self.highlightRanges = highlightRanges
53 self.selectedRange = selectedRange
54 super.init(nibName: nil, bundle: nil)
55 }
56
57 required init?(coder: NSCoder) {
58 fatalError("init(coder:) has not been implemented")
59 }
60
61 override func loadView() {
62 let scrollView = NSTextView.scrollableTextView()
63 let textView = scrollView.documentView as! NSTextView
64
65 scrollView.translatesAutoresizingMaskIntoConstraints = false
66
67 textView.backgroundColor = .UI.background
68 textView.allowsUndo = true
69 textView.delegate = self
70 textView.textStorage?.delegate = self
71 textView.string = self.document.text
72 textView.isEditable = true
73 textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular)
74 self.view = scrollView
75 }
76
77 override func viewDidAppear() {
78 self.view.window?.makeFirstResponder(self.view)
79 updateHighlights()
80 }
81
82 private var textView: NSTextView? {
83 return (view as? NSScrollView)?.documentView as? NSTextView
84 }
85
86 private func updateHighlights() {
87 if let textView {
88 if let textStorage = textView.textStorage {
89 textStorage.removeAttribute(
90 .backgroundColor, range: NSRange(location: 0, length: textStorage.length))
91
92 for range in highlightRanges {
93 let nsRange = NSRange(range, in: textStorage.string)
94
95 textStorage.addAttribute(.backgroundColor, value: NSColor.Syntax.match, range: nsRange)
96 }
97
98 textView.needsDisplay = true
99
100 }
101 }
102 }
103
104 private func focusOnResult() {
105 if let textView {
106 if let textStorage = textView.textStorage {
107 if selectedRange < highlightRanges.count {
108 let range = highlightRanges[selectedRange]
109 let nsRange = NSRange(range, in: textStorage.string)
110 textView.scrollRangeToVisible(nsRange)
111 textView.selectedRange = nsRange
112 }
113 }
114 }
115 }
116
117 private func setSelectionColor() {
118 guard let textView = self.textView else { return }
119
120 var selectedTextAttributes = textView.selectedTextAttributes
121 selectedTextAttributes[.backgroundColor] = NSColor.yellow.withAlphaComponent(0.3)
122 textView.selectedTextAttributes = selectedTextAttributes
123 }
124 }
125
126 extension MapTextEditorController: NSTextViewDelegate {
127
128 func textDidChange(_ obj: Notification) {
129 if let textField = obj.object as? NSTextView {
130 self.document.text = textField.string
131
132 changeDebouncer.debounce {
133 DispatchQueue.main.async {
134 self.onChange()
135 }
136 }
137 }
138 }
139
140 func textView(_ view: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool
141 {
142 let range = Range(shouldChangeTextIn, in: view.string)
143 let target = view.string[range!]
144
145 if target == "--" {
146 return false
147 }
148
149 return true
150 }
151 }
152
153 extension MapTextEditorController: NSTextStorageDelegate {
154
155 override func textStorageDidProcessEditing(_ obj: Notification) {
156 if let textStorage = obj.object as? NSTextStorage {
157 self.colorizeText(textStorage: textStorage)
158 }
159 }
160
161 private func colorizeText(textStorage: NSTextStorage) {
162 let range = NSMakeRange(0, textStorage.length)
163 var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range)
164
165 for match in matches {
166 textStorage.addAttributes(
167 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 1))
168 textStorage.addAttributes(
169 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
170 textStorage.addAttributes(
171 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 3))
172 textStorage.addAttributes(
173 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 4))
174 }
175
176 matches = edgeRegex.matches(in: textStorage.string, options: [], range: range)
177
178 for match in matches {
179 textStorage.addAttributes(
180 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 1))
181 let arrowRange = match.range(at: 2)
182 textStorage.addAttributes(
183 [.foregroundColor: NSColor.Syntax.symbol],
184 range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1))
185 textStorage.addAttributes(
186 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 3))
187 }
188
189 matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range)
190
191 for match in matches {
192 textStorage.addAttributes(
193 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
194 textStorage.addAttributes(
195 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
196 textStorage.addAttributes(
197 [.foregroundColor: NSColor.Syntax.symbol], range: match.range(at: 3))
198 textStorage.addAttributes(
199 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 4))
200 }
201
202 matches = blockerRegex.matches(in: textStorage.string, options: [], range: range)
203
204 for match in matches {
205 textStorage.addAttributes(
206 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
207 textStorage.addAttributes(
208 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
209 }
210
211 matches = noteRegex.matches(in: textStorage.string, options: [], range: range)
212
213 for match in matches {
214 textStorage.addAttributes(
215 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
216 textStorage.addAttributes(
217 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
218 textStorage.addAttributes(
219 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 3))
220 }
221
222 matches = stageRegex.matches(in: textStorage.string, options: [], range: range)
223
224 for match in matches {
225 textStorage.addAttributes(
226 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
227 textStorage.addAttributes(
228 [.foregroundColor: NSColor.Syntax.number], range: match.range(at: 2))
229 }
230
231 matches = groupRegex.matches(in: textStorage.string, options: [], range: range)
232
233 for match in matches {
234 textStorage.addAttributes(
235 [.foregroundColor: NSColor.Syntax.option], range: match.range(at: 1))
236 textStorage.addAttributes(
237 [.foregroundColor: NSColor.Syntax.vertex], range: match.range(at: 2))
238 }
239 }
240 }
241
242 struct MapTextEditor: NSViewControllerRepresentable {
243
244 @Binding var document: MapDocument
245 var highlightRanges: [Range<String.Index>]
246 var selectedRange: Int
247 var onChange: () -> Void = {}
248
249 func makeNSViewController(
250 context: NSViewControllerRepresentableContext<MapTextEditor>
251 ) -> MapTextEditorController {
252 return MapTextEditorController(
253 document: $document, highlightRanges: highlightRanges, selectedRange: selectedRange,
254 onChange: onChange)
255 }
256
257 func updateNSViewController(
258 _ nsViewController: MapTextEditorController,
259 context: NSViewControllerRepresentableContext<MapTextEditor>
260 ) {
261 nsViewController.highlightRanges = highlightRanges
262 nsViewController.selectedRange = selectedRange
263 }
264 }