()) {
283 | complete (["picked.gd"])
284 | }
285 |
286 | override func requestOpen(path: String) {
287 | print ("File \(path) shoudl be opened")
288 | }
289 | }
290 |
291 | struct DemoCodeEditorShell: View {
292 | @State var state: CodeEditorState = DemoCodeEditorState()
293 |
294 | var body: some View {
295 | VStack {
296 | Button("Show Go-To Line") {
297 | state.showGotoLine = true
298 | }
299 |
300 | Text ("\(Bundle.main.resourceURL) xx Path=\(Bundle.main.paths(forResourcesOfType: ".gd", inDirectory: "/tmp"))")
301 | CodeEditorShell (state: state) { request in
302 | print ("Loading \(request)")
303 | return nil
304 | } emptyView: {
305 | Text ("No Files Open")
306 | }
307 | .onAppear {
308 | _ = state.openHtml(title: "Help", path: "foo.html", content: "Hellohack")
309 | switch state.openFile(path: "/etc/passwd", delegate: nil, fileHint: .detect) {
310 | case .success(let item):
311 | item.validationResult (
312 | functions: [],
313 | errors: [Issue(kind: .error, col: 1, line: 1, message: "Demo Error, with a very long descrption that makes it up for the very difficult task of actually having to wrap around")],
314 | warnings: [Issue(kind: .warning, col: 1, line: 1, message: "Demo Warning")])
315 | case .failure(let err):
316 | print ("Error: \(err)")
317 | break
318 | }
319 | }
320 | }
321 | }
322 | }
323 |
324 | #Preview {
325 | ZStack {
326 | Color(uiColor: .systemGray6)
327 | DemoCodeEditorShell ()
328 | }
329 | }
330 | #endif
331 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/CodeEditorState.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/1/24.
6 | //
7 |
8 | import Foundation
9 | import Runestone
10 | import RunestoneUI
11 | import SwiftUI
12 |
13 | ///
14 | /// Tracks the state for the editor, you can affect the editor by invoking methods in this API
15 | ///
16 | /// You can load files using the `openFile` method, or render local HTML content using the `openHtml` method.
17 | ///
18 | /// You must subclass this and implement the following methods:
19 | /// - `readFileContents`
20 | /// - `requestFileSaveAs`
21 | /// - `requestOpen`
22 | /// - `requestFile Open`
23 | /// - `fileList`
24 | @Observable
25 | @MainActor
26 | open class CodeEditorState {
27 | public var openFiles: [HostedItem]
28 |
29 | /// Index of the currentEditor
30 | public var currentEditor: Int? = nil {
31 | didSet {
32 | updateCurrentTextEditor()
33 | }
34 | }
35 |
36 | /// If true, it means that the currently selected editor in `currentEditor` is a text editor
37 | public var currentTabIsTextEditor: Bool = false
38 |
39 | var completionRequest: CompletionRequest? = nil
40 | var saveError: Bool = false
41 | var saveErrorMessage = ""
42 | var saveIdx = 0
43 | var codeEditorDefaultTheme: CodeEditorDefaultTheme
44 |
45 | /// Whether to show the path browser
46 | public var showPathBrowser: Bool = true
47 |
48 | public var lineHeightMultiplier: CGFloat = 1.6
49 |
50 | /// Configures whether the editors show line numbers
51 | public var showLines: Bool = true
52 |
53 | /// Configures whether the editors show tabs
54 | public var showTabs: Bool = false
55 |
56 | /// Configures whether the editors show various space indicators
57 | public var showSpaces: Bool = false
58 |
59 | /// Configures whether we auto-delete empty pairs (like quotes, parenthesis)
60 | public var autoDeleteEmptyPairs: Bool = true
61 |
62 | /// Controls word wrapping in the text editor
63 | public var lineWrapping: Bool = true
64 |
65 | /// If true, displays a file menu, otherwise it does not
66 | public var showFileMenu: Bool = false
67 |
68 | /// Controls displaying the "Go To Line" dialog
69 | public var showGotoLine: Bool = false
70 |
71 | /// Controls font size
72 | public var fontSize: CGFloat = 16 {
73 | didSet {
74 | self.codeEditorDefaultTheme = CodeEditorDefaultTheme(fontSize: fontSize)
75 | }
76 | }
77 |
78 | /// Controls indentation strategy
79 | public var indentStrategy: IndentStrategy = .tab(length: 4)
80 |
81 | /// Initializes the code editor state that you can use to control what is shown
82 | public init(openFiles: [EditedItem] = []) {
83 | self.openFiles = openFiles
84 | currentEditor = openFiles.count > 0 ? 0 : nil
85 | self.codeEditorDefaultTheme = CodeEditorDefaultTheme(fontSize: 16)
86 | updateCurrentTextEditor()
87 | }
88 |
89 | func updateCurrentTextEditor() {
90 | if let currentEditor, currentEditor < openFiles.count, openFiles[currentEditor] is EditedItem {
91 | currentTabIsTextEditor = true
92 | } else {
93 | currentTabIsTextEditor = false
94 | }
95 | }
96 |
97 | public func getCurrentEditedItem() -> EditedItem? {
98 | guard let currentEditor, currentEditor < openFiles.count else { return nil }
99 |
100 | guard let edited = openFiles[currentEditor] as? EditedItem else {
101 | return nil
102 | }
103 | return edited
104 | }
105 |
106 | /// If the path is currently being edited, it returns the EditedItem for it,
107 | /// otherwise it returns nil
108 | public func getEditedFile(path: String) -> EditedItem? {
109 | if let existingIdx = openFiles.firstIndex(where: {
110 | $0 is EditedItem && $0.path == path
111 | }) {
112 | if let result = openFiles [existingIdx] as? EditedItem {
113 | return result
114 | }
115 | }
116 | return nil
117 | }
118 |
119 | public func getEditedItem(path: String) -> HostedItem? {
120 | if let existingIdx = openFiles.firstIndex(where: {
121 | $0.path == path
122 | }) {
123 | return openFiles[existingIdx]
124 | }
125 | return nil
126 |
127 | }
128 |
129 | /// Must be implemented in subclasses, the default implementation uses the host API
130 | open func readFileContents(path: String) -> Result {
131 | do {
132 | return .success (try String(contentsOf: URL (filePath: path)))
133 | } catch (let err) {
134 | if !FileManager.default.fileExists(atPath: path) {
135 | return .failure(.fileNotFound(path))
136 | }
137 | return .failure(.generic(err.localizedDescription))
138 | }
139 | }
140 |
141 | /// Requests that a file with the given path be opened by the code editor
142 | /// - Parameters:
143 | /// - path: The filename to load, this is loaded via the `readFileContents` API
144 | /// - delegate: the delegate to fulfill services for this edited item
145 | /// - fileHint: hint, if available about the kind of file we are editing
146 | /// - breakpoints: List of breakpoints to show at startup as shown.
147 | /// - Returns: an EditedItem if it was alread opened, or if it was freshly opened on success, or an error indicating the problem otherwise
148 | public func openFile (path: String, delegate: EditedItemDelegate?, fileHint: EditedItem.FileHint, breakpoints: Set = Set()) -> Result {
149 | if let existingIdx = openFiles.firstIndex(where: { $0 is EditedItem && $0.path == path }) {
150 | if let result = openFiles [existingIdx] as? EditedItem {
151 | currentEditor = existingIdx
152 | return .success(result)
153 | }
154 | }
155 | switch readFileContents(path: path) {
156 | case .success(let content):
157 | let item = EditedItem(path: path, content: content, editedItemDelegate: delegate, fileHint: .detect, breakpoints: breakpoints)
158 | openFiles.append(item)
159 | currentEditor = openFiles.count - 1
160 | return .success(item)
161 | case .failure(let code):
162 | return .failure(code)
163 | }
164 | }
165 |
166 | /// Requests that a file with the given path be opened by the code editor
167 | /// - Parameters:
168 | /// - path: The filename to load, this is loaded via the `attemptOpenFile` method
169 | /// - delegate: the delegate to fulfill services for this edited item
170 | /// - fileHint: hint, if available about the kind of file we are editing
171 | /// - breakpoints: List of breakpoints to show at startup as shown.
172 | /// - Returns: an EditedItem if it was alread opened, or if it was freshly opened on success, or an error indicating the problem otherwise
173 | public func editFile (path: String, contents: String, delegate: EditedItemDelegate?, fileHint: EditedItem.FileHint, breakpoints: Set = Set()) -> EditedItem {
174 | if let existingIdx = openFiles.firstIndex(where: { $0 is EditedItem && $0.path == path }) {
175 | if let result = openFiles [existingIdx] as? EditedItem {
176 | currentEditor = existingIdx
177 | return result
178 | }
179 | }
180 | let item = EditedItem(path: path, content: contents, editedItemDelegate: delegate, fileHint: fileHint, breakpoints: breakpoints)
181 | openFiles.append(item)
182 | currentEditor = openFiles.count - 1
183 | return item
184 | }
185 |
186 | public func addSwiftUIItem(item: SwiftUIHostedItem) {
187 | openFiles.append(item)
188 | currentEditor = openFiles.count - 1
189 | }
190 |
191 | /// Opens an HTML tab with the specified HTML content
192 | /// - Parameters:
193 | /// - title: Title to display on the tab bar
194 | /// - path: used for matching open tabs, it should represent the content that rendered this
195 | /// - content: the HTML content to display.
196 | /// - Returns: the HtmlItem for this path.
197 | public func openHtml (title: String, path: String, content: String, anchor: String? = nil) -> HtmlItem {
198 | if let existingIdx = openFiles.firstIndex(where: { $0 is HtmlItem && $0.path == path }) {
199 | if let result = openFiles [existingIdx] as? HtmlItem {
200 | currentEditor = existingIdx
201 | if result.anchor != anchor {
202 | result.anchor = anchor
203 | }
204 | return result
205 | }
206 | }
207 | let html = HtmlItem(title: title, path: path, content: content, anchor: anchor)
208 | openFiles.append (html)
209 | currentEditor = openFiles.count - 1
210 | return html
211 | }
212 |
213 | /// If the given path is already open, it returns it, and switches to it
214 | public func findExistingHtmlItem (path: String) -> HtmlItem? {
215 | if let existingIdx = openFiles.firstIndex(where: { $0 is HtmlItem && $0.path == path }) {
216 | if let result = openFiles [existingIdx] as? HtmlItem {
217 | currentEditor = existingIdx
218 | return result
219 | }
220 | }
221 | return nil
222 | }
223 |
224 | @MainActor
225 | public func attemptSave (_ idx: Int) -> Bool {
226 | guard let edited = openFiles[idx] as? EditedItem, edited.dirty else {
227 | return true
228 | }
229 | saveIdx = idx
230 | if let error = edited.editedItemDelegate?.save(editedItem: edited, contents: edited.content, newPath: nil) {
231 | saveErrorMessage = error.localizedDescription
232 | saveError = true
233 | return false
234 | }
235 | edited.dirty = false
236 | return true
237 | }
238 |
239 | @MainActor
240 | public func save(editedItem: EditedItem) -> HostServiceIOError? {
241 | guard editedItem.dirty else {
242 | return nil
243 | }
244 | if let error = editedItem.editedItemDelegate?.save(editedItem: editedItem, contents: editedItem.content, newPath: nil) {
245 | return error
246 | }
247 | editedItem.dirty = false
248 | return nil
249 | }
250 | @MainActor
251 | func attemptClose (_ idx: Int) {
252 | guard idx < openFiles.count else { return }
253 | if let edited = openFiles[idx] as? EditedItem, edited.dirty {
254 | if attemptSave (idx) {
255 | closeFile (idx)
256 | }
257 | } else {
258 | closeFile (idx)
259 | }
260 | }
261 |
262 | @MainActor
263 | func closeFile (_ idx: Int) {
264 | guard idx < openFiles.count else { return }
265 | if let edited = openFiles[idx] as? EditedItem {
266 | edited.editedItemDelegate?.closing(edited)
267 | }
268 | openFiles.remove(at: idx)
269 | if idx == currentEditor {
270 | if openFiles.count == 0 {
271 | currentEditor = nil
272 | } else {
273 | if let ce = currentEditor {
274 | if ce == 0 {
275 | if openFiles.count > 0 {
276 | currentEditor = 0
277 | } else {
278 | currentEditor = nil
279 | }
280 | } else {
281 | currentEditor = ce - 1
282 | }
283 | }
284 | }
285 | }
286 | }
287 |
288 | /// Saves the current file if it is dirty
289 | @MainActor
290 | public func saveCurrentFile(newPath: String? = nil) {
291 | guard let idx = currentEditor else { return }
292 | guard let edited = openFiles[idx] as? EditedItem, edited.dirty else { return }
293 | if let error = edited.editedItemDelegate?.save(editedItem: edited, contents: edited.content, newPath: newPath) {
294 | saveErrorMessage = error.localizedDescription
295 | saveError = true
296 | }
297 | edited.dirty = false
298 | }
299 |
300 | /// Invokes to save a file, it gets a title, and an initial path to display, this should display
301 | /// the UI to request a file to be opened, and when the user picks the target, the complete
302 | /// callback should be invoked with an array that contains a single string with the destination path where
303 | /// the file will be saved.
304 | ///
305 | /// - Parameters:
306 | /// - title: Desired title to show in the UI for the dialog to save
307 | /// - path: the initial path to display in the dialog
308 | /// - complete: the method to invoke on the user picking the file, it should contains a string with the destination path, only the first
309 | /// element is used is currently used.
310 | open func requestFileSaveAs(title: LocalizedStringKey, path: String, complete: @escaping ([String]) -> ()) {
311 | complete([])
312 | }
313 |
314 | /// Invoked to request that the file open dialog is displayed
315 | ///
316 | open func requestFileOpen(title: LocalizedStringKey, path: String, complete: @escaping ([String]) -> ()) {
317 | print("Request file open for \(title) at \(path)")
318 | }
319 |
320 | /// Used to request that the shell environment opens the specified path.
321 | open func requestOpen(path: String) {
322 | }
323 |
324 | /// Used to return the file contents at path, you can override this
325 | open func fileList(at path: String) -> [DirectoryElement] {
326 | var result: [DirectoryElement] = []
327 | do {
328 | for element in try FileManager.default.contentsOfDirectory(atPath: path) {
329 | var isDir: ObjCBool = false
330 | if FileManager.default.fileExists(atPath: "\(path)/\(element)", isDirectory: &isDir) {
331 | result.append (DirectoryElement(name: element, isDir: isDir.boolValue))
332 | }
333 | }
334 | } catch {
335 | return result
336 | }
337 | result.sort(by: {
338 | if $0.isDir {
339 | if $1.isDir {
340 | return $0.name < $1.name
341 | } else {
342 | return false
343 | }
344 | } else {
345 | if $1.isDir {
346 | return true
347 | } else {
348 | return $0.name < $1.name
349 | }
350 | }
351 | })
352 | return result
353 | }
354 |
355 | //
356 | // Triggers the workflow to save the current file with a new path
357 | @MainActor
358 | public func saveFileAs() {
359 | guard let currentEditor else { return }
360 | guard let edited = openFiles[currentEditor] as? EditedItem else { return }
361 | let path = edited.path
362 |
363 | requestFileSaveAs(title: "Save Script As", path: path) { ret in
364 | guard let newPath = ret.first else { return }
365 | edited.path = newPath
366 | if let error = edited.editedItemDelegate?.save(editedItem: edited, contents: edited.content, newPath: newPath) {
367 | self.saveErrorMessage = error.localizedDescription
368 | self.saveError = true
369 | }
370 | edited.dirty = false
371 | }
372 | }
373 |
374 | @MainActor
375 | public func saveAllFiles() {
376 | for idx in 0.. [HostedItem] {
391 | return openFiles
392 | }
393 |
394 | public func selectFile(path: String) {
395 | if let idx = openFiles.firstIndex(where: { $0.path == path }) {
396 | currentEditor = idx
397 | }
398 | }
399 |
400 | public func search (showReplace: Bool) {
401 | guard let currentEditor else { return }
402 | let item = openFiles[currentEditor]
403 | if showReplace {
404 | item.requestFindAndReplace()
405 | } else {
406 | item.requestFind()
407 | }
408 | //item.findRequest = showReplace ? .findAndReplace : .find
409 | }
410 |
411 | public func goTo (line: Int) {
412 | guard let currentEditor else { return }
413 | if let item = openFiles[currentEditor] as? EditedItem {
414 | item.commands.requestGoto(line: line)
415 | }
416 | }
417 |
418 | public func nextTab () {
419 | guard let currentEditor else {
420 | if openFiles.count > 0 {
421 | self.currentEditor = 0
422 | }
423 | return
424 | }
425 | if currentEditor+1 < openFiles.count {
426 | self.currentEditor = currentEditor + 1
427 | } else {
428 | self.currentEditor = 0
429 | }
430 | }
431 |
432 | public func previousTab () {
433 | guard let currentEditor else {
434 | if openFiles.count > 0 {
435 | self.currentEditor = openFiles.count - 1
436 | }
437 |
438 | return
439 | }
440 | if currentEditor > 0 {
441 | self.currentEditor = currentEditor - 1
442 | } else {
443 | self.currentEditor = openFiles.count - 1
444 | }
445 | }
446 |
447 | /// Indicates whether we have an empty set of tabs or not
448 | public var haveScriptOpen: Bool {
449 | var haveEditor = false
450 | for x in openFiles {
451 | if x is EditedItem {
452 | return true
453 | }
454 | }
455 | return false
456 | }
457 |
458 | public func hasFirstResponder() -> Bool {
459 | guard let currentEditor else { return false }
460 | if let edited = openFiles[currentEditor] as? EditedItem {
461 | if edited.commands.textView?.isFirstResponder ?? false {
462 | return true
463 | }
464 | }
465 | return false
466 | }
467 |
468 | @MainActor
469 | public func toggleInlineComment() {
470 | guard let currentEditor else { return }
471 | guard let edited = openFiles[currentEditor] as? EditedItem else {
472 | return
473 | }
474 | edited.toggleInlineComment()
475 | }
476 |
477 | /// This callback receives both an instance to the state so it can direct the process, and a handle to the TextView that triggered the change
478 | /// and can be used to extract information about the change.
479 | // public var onChange: ((CodeEditorState, EditedItem, TextView)->())? = nil
480 | //
481 | // func change (_ editedItem: EditedItem, _ textView: TextView) {
482 | // guard let onChange else {
483 | // return
484 | // }
485 | // onChange (self, editedItem, textView)
486 | // }
487 | }
488 |
489 | /// This packet describes the parameters to trigger the code compeltion window
490 | struct CompletionRequest {
491 | let at: CGRect
492 | let on: TextView
493 | let prefix: String
494 | let completions: [CompletionEntry]
495 | let textViewCursor: Int
496 | }
497 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/CodeEditorView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CodeEditorView.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 3/29/24.
6 | //
7 |
8 | import SwiftUI
9 | import RunestoneUI
10 | import TreeSitterGDScriptRunestone
11 | import Runestone
12 | import UniformTypeIdentifiers
13 |
14 | enum CodeEditorStatus {
15 | case ok
16 | case notFound
17 | }
18 |
19 | public struct CodeEditorView: View, DropDelegate, TextViewUIDelegate {
20 | @State var codeEditorSize: CGSize = .zero
21 | @Binding var contents: String
22 | @State var status: CodeEditorStatus
23 | @State var keyboardOffset: CGFloat = 0
24 | @State var lookupWord: String = ""
25 | @State var completionInProgress: Bool = false
26 | @State var textOffset: CGFloat = 0
27 |
28 | var item: EditedItem
29 | let state: CodeEditorState
30 |
31 | public init (state: CodeEditorState, item: EditedItem, contents: Binding) {
32 | self.state = state
33 | self.item = item
34 | self._status = State(initialValue: .ok)
35 | self._contents = contents
36 | }
37 |
38 | public func uitextViewChanged(_ textView: Runestone.TextView) {
39 | item.editedTextChanged(on: textView)
40 | }
41 |
42 | public func uitextViewDidChangeSelection(_ textView: TextView) {
43 | item.editedTextSelectionChanged(on: textView)
44 | }
45 |
46 | public func uitextViewLoaded(_ textView: Runestone.TextView) {
47 | item.started(on: textView)
48 | }
49 |
50 | public func uitextViewGutterTapped(_ textView: Runestone.TextView, line: Int) {
51 | item.gutterTapped(on: textView, line: line)
52 | }
53 |
54 | public func uitextViewRequestWordLookup(_ textView: Runestone.TextView, at position: UITextPosition, word: String) {
55 | item.editedItemDelegate?.lookup(item, on: textView, at: position, word: word)
56 | }
57 |
58 | public func uitextViewTryCompletion() -> Bool {
59 | if let completionRequest = item.completionRequest {
60 | insertCompletion ()
61 | return true
62 | } else {
63 | return false
64 | }
65 | }
66 |
67 | func insertCompletion () {
68 | guard let req = item.completionRequest else { return }
69 | completionInProgress = true
70 | if item.selectedCompletion > req.completions.count {
71 | print("item.selectedCompletion=\(item.selectedCompletion) > req.completions.count=\(req.completions.count)")
72 | return
73 | }
74 | let insertFull = req.completions[item.selectedCompletion].insert
75 | let count = req.prefix.count
76 | let startLoc = req.on.selectedRange.location-count
77 | if startLoc >= 0 {
78 | var r = NSRange (location: startLoc, length: count)
79 | if var currentText = req.on.text(in: r) {
80 | if insertFull.first == "\"" && insertFull.last == "\"" && currentText.first == "\"" {
81 | if let suffix = req.on.text(in: NSRange(location: r.location + r.length, length: 1)), suffix == "\"" {
82 | r.length += 1
83 | }
84 |
85 | }
86 | }
87 | req.on.replace(r, withText: insertFull)
88 | }
89 |
90 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
91 | item.cancelCompletion()
92 | self.completionInProgress = false
93 | }
94 |
95 | }
96 |
97 | // Implementation of the DropDelegate method
98 | public func performDrop(info: DropInfo) -> Bool {
99 | let cmd = item.commands
100 | guard let pos = cmd.closestPosition(to: info.location) else { return false }
101 | guard let range = cmd.textRange (from: pos, to: pos) else { return false }
102 |
103 | let result = Accumulator (range: range, cmd: cmd)
104 | var pending = 0
105 |
106 | for provider in info.itemProviders(for: [.text, .data]) {
107 | if provider.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
108 | pending += 1
109 | _ = provider.loadItem(forTypeIdentifier: UTType.data.identifier) { data, _ in
110 | Task {
111 | if let data = data as? Data, let file = try? JSONDecoder().decode(FileNode.self, from: data) {
112 |
113 | for url in file.urls {
114 | await result.push("\"\(url)\"")
115 | }
116 | } else if let data = data as? Data, let scene = try? JSONDecoder().decode(SceneNode.self, from: data) {
117 | var path = scene.path
118 | if path.contains(".") {
119 | let prefix = String(path.removeFirst())
120 | path = "\"" + path + "\""
121 | result.push(prefix + path)
122 | } else {
123 | await result.push(scene.path)
124 | }
125 | } else {
126 | await result.error()
127 | return
128 | }
129 |
130 | }
131 | }
132 | }
133 |
134 | if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
135 | pending += 1
136 | provider.loadItem(forTypeIdentifier: UTType.text.identifier) { data, error in
137 | Task {
138 | if let data = data as? Data, let text = String(data: data, encoding: .utf8) {
139 | await result.push (text)
140 | } else {
141 | await result.error ()
142 | }
143 | }
144 | }
145 | }
146 | }
147 | Task {
148 | await result.waitFor (pending)
149 | }
150 | return true
151 | }
152 |
153 | // Needed so we can show the cursor moving
154 | public func dropEntered(info: DropInfo) {
155 | item.commands.textView?.becomeFirstResponder()
156 | }
157 |
158 | // Update the cursor position near the drop site.
159 | public func dropUpdated(info: DropInfo) -> DropProposal? {
160 | let cmd = item.commands
161 | guard let pos = cmd.closestPosition(to: info.location) else { return nil }
162 |
163 | cmd.selectedTextRange = cmd.textRange(from: pos, to: pos)
164 |
165 | return nil
166 | }
167 |
168 | public var body: some View {
169 | ZStack (alignment: .topLeading){
170 | let b = Bindable(item)
171 | TextViewUI (text: $contents,
172 | commands: item.commands,
173 | keyboardOffset: $keyboardOffset,
174 | breakpoints: b.breakpoints,
175 | delegate: self
176 | )
177 | .highlightLine(item.currentLine)
178 | .onDisappear {
179 | // When we go away, clear the completion request
180 | item.completionRequest = nil
181 | }
182 | .focusable()
183 | .spellChecking(.no)
184 | .autoCorrection(.no)
185 | .includeLookupSymbol(item.supportsLookup)
186 | .onKeyPress(.downArrow) {
187 | if let req = item.completionRequest {
188 | if item.selectedCompletion < req.completions.count {
189 | item.selectedCompletion += 1
190 | }
191 | return .handled
192 | }
193 | return .ignored
194 | }
195 | .onKeyPress(.upArrow) {
196 | if item.completionRequest != nil {
197 | if item.selectedCompletion > 0 {
198 | item.selectedCompletion -= 1
199 | }
200 | return .handled
201 | }
202 | return .ignored
203 | }
204 | .onKeyPress(.leftArrow) {
205 | item.completionRequest = nil
206 | return .ignored
207 | }
208 | .onKeyPress(.rightArrow) {
209 | item.completionRequest = nil
210 | return .ignored
211 | }
212 | .onKeyPress(.return) {
213 | if item.completionRequest != nil {
214 | insertCompletion ()
215 | return .handled
216 | }
217 | return .ignored
218 | }
219 | .onKeyPress(.escape) {
220 | if item.completionRequest != nil {
221 | item.completionRequest = nil
222 | return .handled
223 | }
224 | return .ignored
225 | }
226 | .onDrop(of: [.text, .data], delegate: self)
227 | .language (item.language)
228 | .lineHeightMultiplier(state.lineHeightMultiplier)
229 | .showTabs(state.showTabs)
230 | .showLineNumbers(state.showLines)
231 | .lineWrappingEnabled(state.lineWrapping)
232 | .showSpaces(state.showSpaces)
233 | .characterPairs(codingPairs)
234 | .highlightLine(item.currentLine)
235 | .characterPairTrailingComponentDeletionMode(
236 | state.autoDeleteEmptyPairs ? .immediatelyFollowingLeadingComponent : .disabled)
237 | .theme(state.codeEditorDefaultTheme)
238 | .indentStrategy(state.indentStrategy)
239 | if let req = item.completionRequest, !completionInProgress {
240 | let (xOffset, yOffset, maxHeight) = calculateOffsetAndHeight(req: req)
241 | CompletionsDisplayView(
242 | prefix: req.prefix,
243 | completions: req.completions,
244 | selected: Binding (get: { item.selectedCompletion}, set: { newV in
245 | if newV >= req.completions.count {
246 | print("Attempting to put a value outside of the range")
247 | return
248 | }
249 | item.selectedCompletion = newV
250 | }),
251 | onComplete: insertCompletion)
252 | .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification), perform: { _ in
253 | if let req = item.completionRequest {
254 | self.item.completionRequest = nil
255 | }
256 | })
257 | .background { Color (uiColor: .systemBackground) }
258 | .offset(x: xOffset, y: yOffset)
259 | .frame(minWidth: 200, maxWidth: 350, maxHeight: maxHeight)
260 | }
261 | }
262 | .onGeometryChange(for: CGSize.self) { proxy in
263 | proxy.size
264 | } action: { newValue in
265 | codeEditorSize = newValue
266 | }
267 | }
268 |
269 | func calculateOffsetAndHeight(req: CompletionRequest) -> (offsetX: CGFloat, offsetY: CGFloat, height: CGFloat) {
270 | let yBelow = req.at.maxY+8
271 | let yAbove = req.at.minY-10
272 | // Calculate maximum available height either above or below
273 | var maxHeight = min(34 * 6.0, max(yAbove, (keyboardOffset - (req.at.maxY + 8))))
274 | // Calculate xOffset based on current position
275 | let xOffset = min(codeEditorSize.width - 350, req.at.minX)
276 | // Calculate yOffset and determine wheater to put completion above or below based on space
277 | let yOffset = codeEditorSize.height - maxHeight < yBelow ? (yAbove - maxHeight) : yBelow
278 |
279 | return (xOffset, yOffset, maxHeight)
280 | }
281 | }
282 |
283 | let codingPairs = [
284 | BasicCharacterPair(leading: "(", trailing: ")"),
285 | BasicCharacterPair(leading: "{", trailing: "}"),
286 | BasicCharacterPair(leading: "[", trailing: "]"),
287 | BasicCharacterPair(leading: "\"", trailing: "\""),
288 | BasicCharacterPair(leading: "'", trailing: "'")
289 | ]
290 |
291 | struct BasicCharacterPair: CharacterPair {
292 | let leading: String
293 | let trailing: String
294 | }
295 |
296 | /// We use this accumultator because we can receive multiple drop files, and each one of those is resolved
297 | /// in the background - when all of those are collected, we can insert the results.
298 | actor Accumulator {
299 | let range: UITextRange
300 | let cmd: TextViewCommands
301 |
302 | init (range: UITextRange, cmd: TextViewCommands) {
303 | result = ""
304 | count = 0
305 | self.range = range
306 | self.cmd = cmd
307 | }
308 |
309 | func push (_ item: String) {
310 | if result != "" {
311 | result += ", "
312 | }
313 | result += item
314 | bump()
315 | }
316 |
317 | func error () {
318 | bump()
319 | }
320 |
321 | func bump () {
322 | count += 1
323 | if count == waitingFor {
324 | flush ()
325 | }
326 | }
327 |
328 | func waitFor(_ count: Int) {
329 | waitingFor = count
330 | if self.count == waitingFor {
331 | flush()
332 | }
333 | }
334 |
335 | // When we are done, invoke the command
336 | func flush () {
337 | let value = result
338 | DispatchQueue.main.async {
339 | self.cmd.replace(self.range, withText: value)
340 | }
341 | }
342 |
343 | var result: String
344 | var count: Int
345 | var waitingFor = Int.max
346 | }
347 |
348 | public struct FileNode: Codable, Sendable {
349 | public let urls: [String]
350 | public let localId: String
351 |
352 | public init(urls: [String], localId: String) {
353 | self.urls = urls
354 | self.localId = localId
355 | }
356 | }
357 |
358 | public struct SceneNode: Codable, Sendable {
359 | public let path: String
360 | public let localId: String
361 |
362 | public init(path: String, localId: String) {
363 | self.path = path
364 | self.localId = localId
365 | }
366 | }
367 |
368 |
369 | #if DEBUG
370 | struct DemoCodeEditorView: View {
371 | @State var text: String = "This is just a sample"
372 |
373 | var body: some View {
374 | CodeEditorView(state: DemoCodeEditorState(),
375 | item: EditedItem(
376 | path: "/Users/miguel/cvs/godot-master/modules/gdscript/tests/scripts/utils.notest.gd",
377 | content: text,
378 | editedItemDelegate: nil),
379 | contents: $text)
380 | }
381 |
382 | func changed(_ editedItem: EditedItem, _ textView: TextView) {
383 | //
384 | }
385 | }
386 | #Preview {
387 | DemoCodeEditorView()
388 | }
389 | #endif
390 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/CompletionEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/3/24.
6 | //
7 |
8 | import Foundation
9 |
10 | public struct CompletionEntry {
11 | /// Need to find a way of making this more generic, this is what GDScript has, but will need different kinds to support
12 | /// other languages in the future.
13 | public enum CompletionKind: Int {
14 | case `class` = 0
15 | case function = 1
16 | case signal = 2
17 | case variable = 3
18 | case member = 4
19 | case `enum` = 5
20 | case constant = 6
21 | case nodePath = 7
22 | case filePath = 8
23 | case plainText = 9
24 | }
25 |
26 | /// The kind of completion, used to style it
27 | public var kind: CompletionKind
28 | /// The text to display in the completion menu
29 | public var display: String
30 | /// The text to insert when the user picks that option
31 | public var insert: String
32 |
33 | public init (kind: CompletionKind, display: String, insert: String) {
34 | self.kind = kind
35 | self.display = display
36 | self.insert = insert
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/CompletionsDisplayView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIView.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/3/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public struct CompletionsDisplayView: View {
11 | @Environment(\.colorScheme) var colorScheme
12 | let prefix: String
13 | var completions: [CompletionEntry]
14 | @Binding var selected: Int
15 | var onComplete: () -> ()
16 | @State var tappedTime: Date? = nil
17 |
18 | func getDefaultAcceptButton (_ color: Color) -> some View {
19 | Image (systemName: "return")
20 | .padding(5)
21 | .background { color }
22 | .foregroundStyle(Color.secondary)
23 | .clipShape(RoundedRectangle(cornerRadius: 4))
24 | }
25 |
26 | let palette: [Color] = [
27 | Color (#colorLiteral(red: 1, green: 0.5204778927, blue: 0.2, alpha: 1)),
28 | Color (#colorLiteral(red: 1, green: 0.7472373315, blue: 0.2049082724, alpha: 1)),
29 | Color (#colorLiteral(red: 0.6337333333, green: 0.194, blue: 0.97, alpha: 1)),
30 | Color (#colorLiteral(red: 0.18, green: 0.4399056839, blue: 0.9, alpha: 1)),
31 | Color (#colorLiteral(red: 0.3110562249, green: 0.178, blue: 0.89, alpha: 1)),
32 | Color (#colorLiteral(red: 0.6041037997, green: 0.93, blue: 0.186, alpha: 1)),
33 | Color (#colorLiteral(red: 0.164, green: 0.82, blue: 0.6884707017, alpha: 1)),
34 | Color (#colorLiteral(red: 0.194, green: 0.7673004619, blue: 0.97, alpha: 1)),
35 | Color (#colorLiteral(red: 1, green: 0.2, blue: 0.6410113414, alpha: 1)),
36 | Color (#colorLiteral(red: 0.3411764801, green: 0.6235294342, blue: 0.1686274558, alpha: 1)),
37 | ]
38 |
39 | func kindToImage (kind: CompletionEntry.CompletionKind) -> some View {
40 | let image: String
41 | let color: Color
42 |
43 | switch kind {
44 | case .class:
45 | image = "c.square.fill"
46 | color = palette[0]
47 | case .function:
48 | image = "f.square.fill"
49 | color = palette[1]
50 | case .constant:
51 | image = "c.square.fill"
52 | color = palette[2]
53 | case .enum:
54 | image = "e.square.fill"
55 | color = palette[3]
56 | case .filePath:
57 | image = "folder.circle.fill"
58 | color = palette[4]
59 | case .member:
60 | image = "m.square.fill"
61 | color = palette[5]
62 | case .nodePath:
63 | image = "n.square.fill"
64 | color = palette[6]
65 | case .plainText:
66 | image = "t.square.fill"
67 | color = palette[7]
68 | case .signal:
69 | image = "s.square.fill"
70 | color = palette[8]
71 | case .variable:
72 | image = "v.square.fill"
73 | color = palette[9]
74 | }
75 | return Image (systemName: image)
76 | .resizable()
77 | .scaledToFit()
78 | .padding(1)
79 | .foregroundStyle(Color.white, color)
80 | .fontWeight(.regular)
81 | .frame(height: 20)
82 | //.frame(width: 20, height: 40)
83 | }
84 |
85 | /// Makes bold text for the text that we were matching against
86 | func boldify (_ source: String, _ hayStack: String) -> Text {
87 | var ra = AttributedString()
88 | let sourceLower = source.lowercased()
89 | var scan = sourceLower [sourceLower.startIndex...]
90 | let plain = UIColor.label
91 | let bolded = UIColor.label.withAlphaComponent(0.6)
92 | for hs in hayStack {
93 | let match = hs.lowercased().first ?? hs
94 |
95 | var ch = AttributedString ("\(hs)")
96 | if scan.count > 0, let p = scan.firstIndex(of: match) {
97 | ch.foregroundColor = plain
98 | scan = scan [scan.index(after: p)...]
99 | } else {
100 | ch.foregroundColor = bolded
101 | }
102 | ra.append (ch)
103 | }
104 | return Text (ra)
105 | }
106 |
107 | func item (prefix: String, _ v: CompletionEntry) -> some View {
108 | HStack (spacing: 0){
109 | boldify (prefix, v.display)
110 | Spacer()
111 | }
112 | .padding (3)
113 | .padding ([.horizontal], 3)
114 | }
115 |
116 | public var body: some View {
117 | // 54 59 70
118 | let highlight = colorScheme == .dark ? Color(red: 0.21, green: 0.23, blue: 0.275) : Color(red: 0.8, green: 0.87, blue: 0.96)
119 |
120 | ScrollView(.vertical){
121 | ScrollViewReader { proxy in
122 | LazyVGrid(columns: [
123 | GridItem(.flexible(), alignment: .leading),
124 | GridItem(.fixed(30), spacing: 3)]){
125 | ForEach (Array(completions.enumerated()), id: \.offset) { idx, entry in
126 | HStack {
127 | kindToImage(kind: entry.kind)
128 | item (prefix: prefix, entry)
129 | }
130 | .frame(minHeight: 29)
131 | .tag(idx)
132 | .padding([.leading], 7)
133 | .background {
134 | if idx == selected {
135 | highlight
136 | }
137 | }
138 | .clipShape(RoundedRectangle(cornerRadius: 4))
139 | .onChange(of: selected) { oldV, newV in
140 | proxy.scrollTo(newV)
141 | }
142 | .onTapGesture {
143 | if idx == selected, tappedTime?.timeIntervalSinceNow ?? 0 > -0.25 {
144 | onComplete()
145 | return
146 | }
147 | selected = idx
148 | tappedTime = Date()
149 | }
150 | if idx == selected {
151 | getDefaultAcceptButton(highlight)
152 | .onTapGesture { onComplete () }
153 | } else {
154 | Text("")
155 | }
156 | }
157 | }
158 | }
159 | }
160 | .padding(4)
161 | .fontDesign(.monospaced)
162 | .font(.footnote)
163 | .background { Color (uiColor: .systemGray6) }
164 | .clipShape (RoundedRectangle(cornerRadius: 6, style: .circular))
165 | .shadow(color: Color (uiColor: .systemGray5), radius: 3, x: 3, y: 3)
166 | .overlay {
167 | RoundedRectangle(cornerRadius: 6, style: .circular)
168 | .stroke(Color (uiColor: .systemGray3), lineWidth: 1) // Add a border
169 |
170 | }
171 |
172 | }
173 | }
174 |
175 | #if DEBUG
176 | struct DemoCompletionsDisplayView: View {
177 | @State var completions: [CompletionEntry] = DemoCompletionsDisplayView.makeTestData ()
178 | @State var selected = 0
179 |
180 | static func makeTestData () -> [CompletionEntry] {
181 | return [
182 | CompletionEntry(kind: .class, display: "print", insert: "print("),
183 | CompletionEntry(kind: .function, display: "print_error", insert: "print_error("),
184 | CompletionEntry(kind: .function, display: "print_another", insert: "print_another("),
185 | CompletionEntry(kind: .class, display: "Poraint", insert: "Poraint"),
186 | CompletionEntry(kind: .variable, display: "apriornster", insert: "apriornster"),
187 | CompletionEntry(kind: .signal, display: "Kind: signal", insert: "print"),
188 | CompletionEntry(kind: .variable, display: "Kind: variable", insert: "print"),
189 | CompletionEntry(kind: .member, display: "Kind: member", insert: "print"),
190 | CompletionEntry(kind: .`enum`, display: ".`enuKind: `", insert: "print"),
191 | CompletionEntry(kind: .constant, display: "Kind: constant", insert: "print"),
192 | CompletionEntry(kind: .nodePath, display: "Kind: nodePath", insert: "print"),
193 | CompletionEntry(kind: .filePath, display: "Kind: filePath", insert: "print"),
194 | CompletionEntry(kind: .plainText, display: "Kind: plainText", insert: "print")
195 |
196 | ]
197 | }
198 | var body: some View {
199 | HStack {
200 | VStack {
201 | CompletionsDisplayView(prefix: "print", completions: completions, selected: $selected, onComplete: { print ("Completing!") })
202 | Spacer ()
203 | }
204 | Spacer ()
205 | }
206 | .padding()
207 | }
208 | }
209 |
210 | #Preview {
211 | DemoCompletionsDisplayView()
212 | }
213 | #endif
214 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/DiagnosticDetails.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/9/24.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | struct ShowIssue: View {
12 | let issue: Issue
13 |
14 | var body: some View {
15 | HStack (alignment: .firstTextBaseline){
16 | Image (systemName: issue.kind == .error ? "xmark.circle.fill" : "exclamationmark.triangle.fill")
17 | .foregroundStyle(issue.kind == .error ? Color.red : Color.orange)
18 | Text ("\(issue.line):\(issue.col) ")
19 | .foregroundStyle(.secondary)
20 | .fontDesign(.monospaced)
21 | + Text ("\(issue.message)")
22 | }
23 | .font(.footnote)
24 | }
25 | }
26 | struct DiagnosticDetailsView: View {
27 | let errors: [Issue]?
28 | let warnings: [Issue]?
29 | let item: EditedItem
30 | let maxFirstLine: CGFloat
31 |
32 | struct DiagnosticView: View {
33 | let src: [Issue]
34 | let item: EditedItem
35 | let maxFirstLine: CGFloat
36 |
37 | var body: some View {
38 | ForEach (Array (src.enumerated()), id: \.offset) { idx, v in
39 | ShowIssue (issue: v)
40 | .onTapGesture {
41 | item.commands.requestGoto(line: v.line-1)
42 | }
43 | .frame(maxWidth: idx == 0 ? maxFirstLine : .infinity, alignment: .leading)
44 | .listRowSeparator(.hidden)
45 | }
46 | }
47 | }
48 |
49 | var body: some View {
50 | List {
51 | if let errors {
52 | DiagnosticView(src: errors, item: item, maxFirstLine: maxFirstLine)
53 | }
54 | if let warnings {
55 | DiagnosticView(src: warnings, item: item, maxFirstLine: maxFirstLine)
56 | }
57 | }
58 | .listStyle(.plain)
59 | }
60 | }
61 |
62 | #Preview {
63 | DiagnosticDetailsView(
64 | errors: [Issue(kind: .error, col: 1, line: 1, message: "My Error, but this is a very long line explaining what went wrong and hy you should not always have text this long that does not have a nice icon aligned")],
65 | warnings: [Issue(kind: .warning, col: 1, line: 1, message: "My Warning")], item: EditedItem(path: "/tmp/", content: "demo", editedItemDelegate: nil), maxFirstLine: .infinity)
66 | }
67 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/EditedItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/5/24.
6 | //
7 |
8 | import Foundation
9 | import Runestone
10 | import RunestoneUI
11 | import TreeSitter
12 | import TreeSitterGDScriptRunestone
13 | import TreeSitterJSONRunestone
14 | import TreeSitterMarkdownRunestone
15 | import TreeSitterGLSLRunestone
16 |
17 | import SwiftUI
18 | /// Represents an edited item in the code editor, it uses a path to reference it, and expect that it
19 | /// can be loaded and saved via the HostServices variable.
20 | @Observable
21 | public class EditedItem: HostedItem {
22 | public enum FileHint {
23 | case detect
24 | case gdscript
25 | case json
26 | case markdown
27 | case gdshader
28 | }
29 | /// Lines where breakpoint indicators are shown
30 | public var breakpoints: Set
31 |
32 | /// If set, a line to highlight, it means "This is current the debugger is stopped"
33 | public var currentLine: Int?
34 |
35 | /// Controls whether this language supports looking symbosl up
36 | public var supportsLookup: Bool
37 |
38 | /// - Parameters:
39 | /// - path: the path that will be passed to the HostServices API to load and save the file
40 | /// - content: Content that is loaded into the edited item
41 | /// - editedItemDelegate: Provides services on behalf of this item
42 | /// - fileHint: Hint to guess which kind of syntax and indentation to use
43 | /// - breakpoints: List of breakpoints
44 | /// - currentLine: The current line to scroll to on startup
45 | public init (path: String, content: String, editedItemDelegate: EditedItemDelegate?, fileHint: FileHint = .detect, breakpoints: Set = Set(), currentLine: Int? = nil) {
46 | switch fileHint {
47 | case .detect:
48 | if path.hasSuffix(".gd") || path.contains ("::"){
49 | language = TreeSitterLanguage.gdscript
50 | supportsLookup = true
51 | } else if path.hasSuffix (".md") {
52 | language = TreeSitterLanguage.markdown
53 | supportsLookup = false
54 | } else if path.hasSuffix(".gdshader") || path.hasSuffix(".gdshaderinc") {
55 | language = TreeSitterLanguage.glsl
56 | supportsLookup = false
57 | } else {
58 | language = nil
59 | supportsLookup = false
60 | }
61 | case .gdscript:
62 | language = TreeSitterLanguage.gdscript
63 | supportsLookup = true
64 | case .json:
65 | language = TreeSitterLanguage.json
66 | supportsLookup = false
67 | case .markdown:
68 | language = TreeSitterLanguage.markdown
69 | supportsLookup = false
70 | case .gdshader:
71 | supportsLookup = false
72 | language = TreeSitterLanguage.glsl
73 | }
74 | self.editedItemDelegate = editedItemDelegate
75 | self.breakpoints = breakpoints
76 | self.currentLine = currentLine
77 | super.init (path: path, content: content)
78 | }
79 |
80 | /// Returns the filename that is suitable to be displayed to the user
81 | public var filename: String {
82 | if let s = path.lastIndex(of: "/"){
83 | return String (path [path.index(after: s)...])
84 | }
85 | return path
86 | }
87 |
88 | /// Returns a title suitable to be shown on the titlebar
89 | public override var title: String {
90 | filename
91 | }
92 |
93 | /// Delegate
94 | public var editedItemDelegate: EditedItemDelegate?
95 |
96 | public var language: TreeSitterLanguage? = nil
97 |
98 | /// List of detected functions, contains the name of the function and the line location
99 | public var functions: [(String,Int)] = []
100 |
101 | /// Detected errors
102 | public var errors: [Issue]? = nil
103 |
104 | /// Detected warnings
105 | public var warnings: [Issue]? = nil
106 |
107 | /// Whether the buffer has local changes
108 | public var dirty: Bool = false
109 |
110 | /// Mechanism to trigger actions on the TextViewUI
111 | public var commands = TextViewCommands()
112 |
113 | /// Sets the hint generated by the completion
114 | public var hint: String? = nil
115 |
116 | public static func == (lhs: EditedItem, rhs: EditedItem) -> Bool {
117 | lhs === rhs
118 | }
119 |
120 | var completionRequest: CompletionRequest? = nil
121 | var selectedCompletion = 0
122 |
123 | public func requestCompletion (at location: CGRect, on textView: TextView, prefix: String, completions: [CompletionEntry]) {
124 | completionRequest = CompletionRequest(at: location, on: textView, prefix: prefix, completions: completions, textViewCursor: textView.selectedRange.location)
125 | selectedCompletion = 0
126 | }
127 |
128 | public func cancelCompletion () {
129 | completionRequest = nil
130 | }
131 |
132 | /// This is used to set the validation result
133 | public func validationResult (functions: [(String,Int)], errors: [Issue]?, warnings: [Issue]?) {
134 | self.functions = functions
135 | self.errors = errors
136 | self.warnings = warnings
137 | }
138 |
139 | public override func requestFindAndReplace() {
140 | commands.requestFindAndReplace()
141 | }
142 |
143 | public override func requestFind () {
144 | commands.requestFind()
145 | }
146 |
147 | @MainActor
148 | public func editedTextChanged (on textView: TextView) {
149 | dirty = true
150 | editedItemDelegate?.editedTextChanged(self, textView)
151 | }
152 |
153 | @MainActor
154 | public func started (on textView: TextView) {
155 | editedItemDelegate?.editedTextChanged(self, textView)
156 | }
157 |
158 | @MainActor
159 | public func gutterTapped (on textView: TextView, line: Int) {
160 | editedItemDelegate?.gutterTapped (self, textView, line)
161 | }
162 |
163 | public var textLocation = TextLocation(lineNumber: 0, column: 0)
164 |
165 | @MainActor
166 | public func editedTextSelectionChanged (on textView: TextView) {
167 | if let newPos = textView.textLocation(at: textView.selectedRange.location) {
168 | textLocation = newPos
169 | }
170 | guard let completionRequest else { return }
171 | if textView.selectedRange.location != completionRequest.textViewCursor {
172 | self.cancelCompletion()
173 | }
174 | }
175 |
176 | @MainActor
177 | private func getDelimiter () -> String? {
178 | if path.hasSuffix(".gd") || path.contains ("::"){
179 | return "#"
180 | } else if path.hasSuffix(".gdshader") || path.hasSuffix(".gdshaderinc") {
181 | return "//"
182 | }
183 | return nil
184 | }
185 |
186 | @MainActor
187 | public func toggleInlineComment() {
188 | if let delimiter = getDelimiter() {
189 | commands.toggleInlineComment(delimiter)
190 | }
191 | }
192 | }
193 |
194 | /// Protocol describing the callbacks for the EditedItem
195 | @MainActor
196 | public protocol EditedItemDelegate: AnyObject {
197 | /// Editing has started for the given item, this is raised when the TextView has loaded
198 | func started (editedItem: EditedItem, textView: TextView)
199 | /// Invoked when the text in the textView has changed, a chance to extract the data
200 | func editedTextChanged (_ editedItem: EditedItem, _ textView: TextView)
201 | /// Invoked when the gutter is tapped, and it contains the line number that was tapped
202 | func gutterTapped (_ editedItem: EditedItem, _ textView: TextView, _ line: Int)
203 | /// Invoked when the user has requested the "Lookup Definition" from the context menu in the editor, it contains the position where this took place and the word that should be looked up
204 | func lookup (_ editedItem: EditedItem, on: TextView, at: UITextPosition, word: String)
205 | /// Invoked when a closing is imminent on the UI
206 | func closing (_ editedItem: EditedItem)
207 | /// Requests that the given item be saved, returns nil on success or details on error, if newPath is not-nil, save to a new filename
208 | func save(editedItem: EditedItem, contents: String, newPath: String?) -> HostServiceIOError?
209 | }
210 |
211 | public struct Issue {
212 | public enum Kind {
213 | case warning
214 | case error
215 | }
216 | var kind: Kind
217 | var col, line: Int
218 | var message: String
219 |
220 | public init (kind: Kind, col: Int, line: Int, message: String) {
221 | self.kind = kind
222 | self.col = col
223 | self.line = line
224 | self.message = message
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/EditorTab.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorTab.swift: Displays some editor tabs on top of the buffers
3 | //
4 | //
5 | // Created by Miguel de Icaza on 4/1/24.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | struct EditorTab2: View {
12 | @Binding var item: HostedItem
13 | @ScaledMetric var internalPadding = 4
14 | let selected: Bool
15 | let close: () -> ()
16 | let select: () -> ()
17 | var body: some View {
18 | HStack (spacing: 4) {
19 | Button (action: { close () }) {
20 | Image (systemName: (item as? EditedItem)?.dirty ?? false ? "circle.fill" : "xmark")
21 | .fontWeight(.light)
22 | .foregroundStyle(selected ? Color.accentColor : Color.secondary)
23 | .font(.caption)
24 | }
25 | Text (item.title)
26 | .foregroundStyle(selected ? Color.accentColor : Color.primary)
27 | .onTapGesture {
28 | self.select ()
29 | }
30 |
31 | }
32 | .padding(internalPadding)
33 | .padding ([.trailing], internalPadding)
34 | .background {
35 | selected ? Color.accentColor.opacity(0.3) : Color (uiColor: .secondarySystemBackground)
36 | }
37 | .clipShape(UnevenRoundedRectangle(topLeadingRadius: 5, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: 5, style: .continuous))
38 | .padding([.horizontal], 3)
39 | }
40 | }
41 |
42 | struct EditorTab: View {
43 | @Binding var item: HostedItem
44 | @ScaledMetric var internalPadding = 10
45 | @ScaledMetric var modifiedImageSize = 10
46 | let selected: Bool
47 | let close: () -> ()
48 | let select: () -> ()
49 | var body: some View {
50 | HStack (spacing: 2) {
51 | if selected {
52 | Button (action: { close () }) {
53 | Image (systemName: "xmark.app.fill")
54 | .foregroundStyle(selected ? Color.accentColor : Color.secondary.opacity(0.8))
55 | .font(.caption)
56 | }
57 | }
58 | ZStack {
59 | // The first versio is the wider, and is hidden using the same color
60 | // as the background
61 | Text (item.title)
62 | .fontWeight(.semibold)
63 | .foregroundStyle(.background)
64 | .opacity(0.001)
65 |
66 | // The one that we dispaly
67 | Text (item.title)
68 | .fontWeight(selected ? .semibold : .regular)
69 | .foregroundStyle(selected ? Color.accentColor : Color.secondary)
70 | }
71 | .font(.caption)
72 | .padding(.horizontal, 4)
73 | .onTapGesture {
74 | self.select ()
75 | }
76 | if (item as? EditedItem)?.dirty ?? false {
77 | Image (systemName: "circle.fill")
78 | .fontWeight(.light)
79 | .foregroundStyle(selected ? Color.accentColor : Color.secondary.opacity(0.8))
80 | .font(.system(size: modifiedImageSize))
81 | }
82 | }
83 | .padding(internalPadding)
84 | .padding(.horizontal, 1)
85 | .background {
86 | selected ? Color.accentColor.opacity(0.2) : Color (uiColor: .systemGray5)
87 | }
88 | .clipShape(UnevenRoundedRectangle(topLeadingRadius: 10, bottomLeadingRadius: 10, bottomTrailingRadius: 10, topTrailingRadius: 10, style: .continuous))
89 | }
90 | }
91 | struct EditorTabs: View {
92 | @Binding var selected: Int?
93 | @Binding var items: [HostedItem]
94 | let closeRequest: (Int) -> ()
95 | @ScaledMetric var dividerSize = 12
96 | @ScaledMetric var tabSpacing: CGFloat = 10
97 |
98 | var body: some View {
99 | ScrollView(.horizontal) {
100 | HStack(spacing: tabSpacing) {
101 | if let selected {
102 | ForEach(Array(items.enumerated()), id: \.offset) { idx, item in
103 | EditorTab(item: $items [idx], selected: idx == selected, close: { closeRequest (idx) }, select: { self.selected = idx } )
104 |
105 | }
106 | }
107 | }
108 | }
109 | .scrollIndicators(.hidden)
110 | }
111 | }
112 |
113 | struct DemoEditorTabs: View {
114 | @State var selected: Int? = 2
115 | @State var items: [HostedItem] = [
116 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
117 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
118 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
119 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
120 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
121 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
122 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
123 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
124 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
125 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
126 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
127 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
128 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
129 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
130 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
131 | EditedItem (path: "some/file/foo.txt", content: "Demo", editedItemDelegate: nil),
132 | EditedItem (path: "res://another.txt", content: "Demo", editedItemDelegate: nil),
133 | EditedItem (path: "res://third.txt", content: "Demo", editedItemDelegate: nil),
134 | ]
135 |
136 | var body: some View {
137 | EditorTabs(selected: $selected, items: $items) { closeIdx in
138 | items.remove(at: closeIdx)
139 | if closeIdx == selected {
140 | selected = max (0, (selected ?? 0)-1)
141 | }
142 | }.onAppear {
143 | if let it = items [1] as? EditedItem {
144 | it.dirty = true
145 | }
146 | }
147 | }
148 | }
149 |
150 | #Preview {
151 | ZStack {
152 | Color (uiColor: .secondarySystemBackground)
153 |
154 | DemoEditorTabs()
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/GotoLineView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GotoLineView.swift
3 | // CodeEditorUI
4 | //
5 | // Created by Miguel de Icaza on 5/17/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct GotoLineView: View {
11 | @Binding var showing: Bool
12 | @Environment(\.colorScheme) var colorScheme
13 | @FocusState var inputFocused: Bool
14 | @State var line: String = ""
15 | @State var canGo: Int? = 1
16 | //let maxLines: Int
17 | let callback: (Int) -> ()
18 |
19 | var textInputBox: some View {
20 | VStack(alignment: .leading) {
21 | HStack(alignment: .firstTextBaseline) {
22 | Image(systemName: "magnifyingglass")
23 | .foregroundStyle(.secondary)
24 | TextField("Line Number", text: $line)
25 | .onSubmit {
26 | showing = false
27 | if let canGo {
28 | callback(canGo)
29 | }
30 | }
31 | .onAppear {
32 | inputFocused = true
33 | }
34 | .focused($inputFocused, equals: true)
35 | Button(action: {
36 | line = ""
37 | }) {
38 | Image (systemName: "xmark.circle.fill")
39 | }
40 | .font(.body)
41 | .foregroundStyle(.secondary)
42 | .opacity(line == "" ? 0 : 1)
43 | }
44 | .padding(.vertical, 3)
45 | .font(.title3)
46 | if let canGo {
47 | Text("# Line Number: **\(canGo)**")
48 | .font(.subheadline)
49 | .frame(maxWidth: .infinity, alignment: .leading)
50 | .padding(4)
51 | .background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor))
52 | .foregroundStyle(.white)
53 | }
54 | }
55 | .padding()
56 | .background(RoundedRectangle(cornerRadius: 10).fill(
57 | //Color(uiColor: .systemGray6)
58 | .ultraThickMaterial
59 | ).stroke(Color(uiColor: .systemGray4)))
60 | .shadow(color: colorScheme == .dark ? .clear : Color.gray, radius: 40, x: 10, y: 30)
61 | .onChange(of: line) { old, new in
62 | if let line = Int(new), line > 0 { // }, line < maxLines {
63 | canGo = line
64 | } else {
65 | canGo = nil
66 | }
67 | }
68 | .frame(minWidth: 300, maxWidth: 400)
69 | }
70 |
71 | var body: some View {
72 | ZStack(alignment: .top) {
73 | Color.black.opacity(0.001)
74 | .onTapGesture {
75 | print("Tapped")
76 | showing = false
77 | }
78 | textInputBox
79 | .offset(y: 40)
80 | }
81 | .onKeyPress(.escape) {
82 | showing = false
83 | return .handled
84 | }
85 | }
86 | }
87 |
88 | struct ContentView: View {
89 | @Binding var show: Bool
90 |
91 | var body: some View {
92 | ZStack {
93 | VStack {
94 | Image(systemName: "globe")
95 | .imageScale(.large)
96 | .foregroundStyle(.tint)
97 | Text("Hello, world!")
98 | }
99 | .padding()
100 | if show {
101 | GotoLineView(showing: $show) { line in
102 | print("Use picked line \(line)")
103 | }
104 | }
105 | }
106 | }
107 | }
108 |
109 | #Preview {
110 | @Previewable @State var show = true
111 | ContentView(show: $show)
112 | }
113 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/HostServices.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 3/29/24.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | /// List of possible errors raised by the IO operations
12 | public enum HostServiceIOError: Error, CustomStringConvertible, LocalizedError {
13 | case fileNotFound(String)
14 |
15 | /// Until swift gets typed errors for IO operations, this contains the localizedDescription error that is raised
16 | /// by the native operations.
17 | case generic(String)
18 |
19 | /// Internal assertion, we should not really hit this
20 | case assertion(String)
21 |
22 | public var description: String {
23 | switch self {
24 | case .fileNotFound(let f):
25 | return "File not found \(f)"
26 | case .assertion(let msg):
27 | return "Internal error, this should not happen: \(msg)"
28 | case .generic(let msg):
29 | return msg
30 | }
31 | }
32 |
33 | public var failureReason: String? {
34 | return description
35 | }
36 | public var errorDescription: String? {
37 | return description
38 | }
39 |
40 | }
41 |
42 | public struct DirectoryElement {
43 | public init(name: String, isDir: Bool) {
44 | self.name = name
45 | self.isDir = isDir
46 | }
47 |
48 | public var name: String
49 | public var isDir: Bool
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/HostedItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 5/11/24.
6 | //
7 |
8 | import Foundation
9 |
10 | open class HostedItem: Identifiable, Hashable, Equatable {
11 | /// - Parameters:
12 | /// - path: the path that will be passed to the HostServices API to load and save the file
13 | /// - data: this is data that can be attached to this object and extracted a later point by the user
14 | public init (path: String, content: String) {
15 | self.path = path
16 | self.content = content
17 | }
18 |
19 | public var id: String { path }
20 |
21 | /// The path of the file that we are editing
22 | public var path: String
23 |
24 | /// The content that is initially displayed
25 | public var content: String
26 |
27 | public static func == (lhs: HostedItem, rhs: HostedItem) -> Bool {
28 | lhs === rhs
29 | }
30 |
31 | public func hash(into hasher: inout Hasher) {
32 | path.hash(into: &hasher)
33 | }
34 |
35 | public func requestFindAndReplace() {}
36 | public func requestFind () {}
37 |
38 | /// Returns a title suitable to be shown on the titlebar
39 | open var title: String {
40 | fatalError()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/HtmlItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HtmlItem.swift
3 | //
4 | //
5 | // Created by Miguel de Icaza on 5/11/24.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 | import WebKit
11 |
12 | /// An HTML page that can be embedeed into the CodeEditorShell in a tab
13 | public class HtmlItem: HostedItem {
14 | let _title: String
15 | public var anchor: String? {
16 | didSet {
17 | if let view, let anchor {
18 | view.scrollTo(anchor)
19 | }
20 | }
21 | }
22 | public override var title: String { _title }
23 | weak var view: WKWebView? = nil
24 |
25 | /// Creates an HTML Item that can be shown in the CodeEditorUI
26 | /// - Parameters:
27 | /// - title: Title to show on the tab
28 | /// - path: Path of the item to browse, not visible, used to check if the document is opened
29 | /// - content: The full HTML content to display
30 | /// - anchor: An optional anchor to navigate to
31 | public init (title: String, path: String, content: String, anchor: String? = nil) {
32 | _title = title
33 | self.anchor = anchor
34 | super.init (path: path, content: content)
35 | }
36 | }
37 |
38 | struct WebView: UIViewRepresentable {
39 | var text: String
40 | var anchor: String?
41 | let obj: HtmlItem
42 |
43 | let loadUrl: (URL) -> String?
44 |
45 | init(text: String, anchor: String?, obj: HtmlItem, load: @escaping (URL) -> String?) {
46 | self.text = text
47 | self.anchor = anchor
48 | self.obj = obj
49 | self.loadUrl = load
50 | }
51 |
52 | func makeUIView(context: Context) -> WKWebView {
53 | let view = WKWebView(frame: CGRect.zero, configuration: context.coordinator.configuration)
54 | view.isInspectable = true
55 | view.isFindInteractionEnabled = true
56 | view.navigationDelegate = context.coordinator
57 | obj.view = view
58 | return view
59 | }
60 |
61 | func makeCoordinator() -> WebViewCoordinator {
62 | return WebViewCoordinator (parent: self, loadUrl: loadUrl)
63 | }
64 |
65 | class WebViewCoordinator: NSObject, WKNavigationDelegate, WKURLSchemeHandler {
66 | let configuration: WKWebViewConfiguration
67 | var parent: WebView?
68 | let loadUrl: (URL) -> String?
69 |
70 | func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
71 | guard let request = urlSchemeTask.request as? URLRequest else {
72 | urlSchemeTask.didFailWithError(NSError(domain: "Godot", code: -1, userInfo: nil))
73 | return
74 | }
75 |
76 | // Extract information from the request
77 | guard let url = urlSchemeTask.request.url else { return }
78 | if url.scheme == "open-external" {
79 | guard let externalUrl = URL (string: String (url.description.dropFirst(14))) else {
80 | return
81 | }
82 | UIApplication.shared.open(externalUrl, options: [:], completionHandler: nil)
83 | return
84 | }
85 | if let anchor = loadUrl (url) {
86 | webView.scrollTo (anchor)
87 | }
88 | }
89 |
90 | func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
91 | //print ("End: \(urlSchemeTask)")
92 | }
93 |
94 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
95 | if let scrollY = parent?.savedScrollY {
96 | let js = "window.scrollTo(0, \(scrollY));"
97 | webView.evaluateJavaScript(js, completionHandler: nil)
98 | }
99 | }
100 |
101 | init (parent: WebView, loadUrl: @escaping (URL)->String?) {
102 | self.parent = parent
103 | configuration = WKWebViewConfiguration()
104 | self.loadUrl = loadUrl
105 | super.init ()
106 | configuration.setURLSchemeHandler(self, forURLScheme: "godot")
107 | configuration.setURLSchemeHandler(self, forURLScheme: "open-external")
108 | }
109 | }
110 | @State var savedScrollY: CGFloat?
111 |
112 | func updateUIView(_ webView: WKWebView, context: Context) {
113 | webView.evaluateJavaScript("window.scrollY") { result, error in
114 | if let scrollY = result as? CGFloat {
115 | if scrollY != 0 {
116 | self.savedScrollY = scrollY
117 | }
118 | }
119 | }
120 |
121 | webView.loadHTMLString(text, baseURL: nil)
122 | if let anchor {
123 | webView.scrollTo (anchor)
124 | }
125 | }
126 | }
127 |
128 | extension WKWebView {
129 | func scrollTo (_ anchor: String) {
130 | DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(1000))) {
131 | let str = "document.getElementById ('\(anchor)').scrollIntoView()"
132 | self.evaluateJavaScript(str) { ret, error in
133 | print ("ScrollRet: \(ret)")
134 | print ("ScrollError: \(error)")
135 | }
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/PathBrowser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PathBrowser.swift
3 | //
4 | // Created by Miguel de Icaza on 4/2/24.
5 | //
6 |
7 | import SwiftUI
8 | import Foundation
9 |
10 | struct PathBrowser: View {
11 | @Environment(CodeEditorState.self) var editorState
12 |
13 | struct IdentifiableInt: Identifiable {
14 | var id: Int
15 | }
16 | let prefix: String
17 | var item: EditedItem
18 | var components: [Substring]
19 | @State var showContents: IdentifiableInt? = nil
20 |
21 | init (item: EditedItem) {
22 | let path = item.path
23 | self.item = item
24 | self.prefix = path.hasPrefix("res://") ? "res://" : "/"
25 |
26 | components = path.dropFirst (path.hasPrefix ("res://") ? 6 : 0).split (separator: "/")
27 | }
28 |
29 | static func makePath (prefix: String, _ components: [Substring], _ idx: Int) -> String {
30 | let r = components [0.. String {
38 | if txt.hasSuffix(".gd") {
39 | return "scroll"
40 | }
41 | if txt.hasSuffix(".md") {
42 | return "text.justify.left"
43 | }
44 | if txt == "README" {
45 | return "book"
46 | }
47 | return "doc"
48 | }
49 |
50 | struct DirectoryView: View {
51 | @Environment(CodeEditorState.self) var editorState
52 | let prefix: String
53 | let basePath: String
54 | let element: String
55 |
56 | var body: some View {
57 | Menu (element) {
58 | ForEach (Array (editorState.fileList(at: basePath).enumerated()), id: \.offset) { _, v in
59 | if v.isDir {
60 | DirectoryView (prefix: prefix, basePath: "\(basePath)/\(v.name)", element: v.name)
61 | } else {
62 | Button (action: {
63 | _ = editorState.requestOpen(path: "\(basePath)/\(v.name)")
64 | }) {
65 | Label(v.name, systemImage: v.isDir ? "folder.fill" : PathBrowser.iconFor(v.name))
66 | }
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
73 | struct FunctionView: View {
74 | let functions: [(String,Int)]
75 | let gotoMethod: (Int) -> ()
76 |
77 | var body: some View {
78 | Menu ("Jump To") {
79 | ForEach (functions, id: \.0) { fp in
80 | Button (action: {
81 | gotoMethod (fp.1)
82 | }) {
83 | Label (fp.0, systemImage: "function")
84 | }
85 | }
86 | }
87 | }
88 | }
89 |
90 | var body: some View {
91 | HStack(spacing: 2) {
92 | ScrollView (.horizontal) {
93 | HStack (spacing: 4) {
94 | ForEach (Array (components.enumerated()), id: \.offset) { idx, v in
95 | if idx == 0 {
96 | Text (prefix)
97 | .foregroundStyle(.secondary)
98 | }
99 |
100 | if idx == components.count-1 {
101 | // For the last element, we display the contents of all the peers, like Xcode
102 | DirectoryView (prefix: prefix, basePath: PathBrowser.makePath (prefix: prefix, components, idx-1), element: String(v))
103 | .fontWeight(.semibold)
104 | } else {
105 | // List the elements of this directory.
106 | DirectoryView (prefix: prefix, basePath: PathBrowser.makePath (prefix: prefix, components, idx), element: String(v))
107 | .foregroundStyle(.primary)
108 | Image (systemName: "chevron.compact.right")
109 | .foregroundColor(.secondary)
110 | }
111 | }
112 | }
113 | .font(.caption)
114 | }
115 | .scrollIndicators(.hidden)
116 |
117 | Spacer ()
118 | if item.functions.count > 0 {
119 | Menu {
120 | FunctionView (functions: item.functions) { line in
121 | // validated
122 | item.commands.requestGoto(line: line)
123 | }
124 | } label: {
125 | Image (systemName: "arrow.down.to.line")
126 | .font(.caption)
127 | .foregroundStyle(Color.primary)
128 | }
129 | .foregroundStyle(.secondary)
130 | }
131 | }
132 | .padding([.vertical], 10)
133 | }
134 |
135 | }
136 |
137 | #if DEBUG
138 | #Preview {
139 | VStack (alignment: .leading){
140 | Text ("Path:")
141 |
142 | PathBrowser(item: EditedItem(path: "res://addons/files/text.gd", content: "demo", editedItemDelegate: nil))
143 | PathBrowser(item: EditedItem(path: "res://users/More/Longer/Very/Long/Path/NotSure/Where/ThisWouldEverEnd/With/ContainersAndOthers/addons/files/text.gd", content: "demo", editedItemDelegate: nil))
144 |
145 | }
146 | .environment(DemoCodeEditorState())
147 | .padding()
148 | }
149 | #endif
150 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/SwiftUIHostedItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIHostedItem.swift
3 | // CodeEditorUI
4 | //
5 | // Created by Miguel de Icaza on 12/18/24.
6 | //
7 | import SwiftUI
8 |
9 | /// An item that hosts a SwiftUI View
10 | open class SwiftUIHostedItem: HostedItem {
11 | /// The view to display, it can be changed
12 | public var view: () -> AnyView
13 |
14 | open override var title: String { "None Set" }
15 |
16 | /// Creates an HTML Item that can be shown in the CodeEditorUI
17 | /// - Parameters:
18 | /// - path: Path of the item to browse, not visible, used to check if the document is opened
19 | /// - content: Data that might be useful to you
20 | /// - view: the SwiftUI View that you want to render, you can change it later
21 | public init (path: String, content: String, view: @escaping () -> AnyView) {
22 | self.view = view
23 | super.init (path: path, content: content)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/CodeEditorUI/utils.notest.gd:
--------------------------------------------------------------------------------
1 | static func get_type(property: Dictionary, is_return: bool = false) -> String:
2 | match property.type:
3 | TYPE_NIL:
4 | if property.usage & PROPERTY_USAGE_NIL_IS_VARIANT:
5 | return "Variant"
6 | return "void" if is_return else "null"
7 | TYPE_INT:
8 | if property.usage & PROPERTY_USAGE_CLASS_IS_ENUM:
9 | if property.class_name == &"":
10 | return ""
11 | return property.class_name
12 | TYPE_ARRAY:
13 | if property.hint == PROPERTY_HINT_ARRAY_TYPE:
14 | if str(property.hint_string).is_empty():
15 | return "Array[]"
16 | return "Array[%s]" % property.hint_string
17 | TYPE_OBJECT:
18 | if not str(property.class_name).is_empty():
19 | return property.class_name
20 | return type_string(property.type)
21 |
22 |
23 | static func get_property_signature(property: Dictionary, base: Object = null, is_static: bool = false) -> String:
24 | if property.usage & PROPERTY_USAGE_CATEGORY:
25 | return '@export_category("%s")' % str(property.name).c_escape()
26 | if property.usage & PROPERTY_USAGE_GROUP:
27 | return '@export_group("%s")' % str(property.name).c_escape()
28 | if property.usage & PROPERTY_USAGE_SUBGROUP:
29 | return '@export_subgroup("%s")' % str(property.name).c_escape()
30 |
31 | var result: String = ""
32 | if not (property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
33 | printerr("Missing `PROPERTY_USAGE_SCRIPT_VARIABLE` flag.")
34 | if is_static:
35 | result += "static "
36 | result += "var " + property.name + ": " + get_type(property)
37 | if is_instance_valid(base):
38 | result += " = " + var_to_str(base.get(property.name))
39 | return result
40 |
41 |
42 | static func get_human_readable_hint_string(property: Dictionary) -> String:
43 | if property.type >= TYPE_ARRAY and property.hint == PROPERTY_HINT_TYPE_STRING:
44 | var type_hint_prefixes: String = ""
45 | var hint_string: String = property.hint_string
46 |
47 | while true:
48 | if not hint_string.contains(":"):
49 | push_error("Invalid PROPERTY_HINT_TYPE_STRING format.")
50 | var elem_type_hint: String = hint_string.get_slice(":", 0)
51 | hint_string = hint_string.substr(elem_type_hint.length() + 1)
52 |
53 | var elem_type: int
54 | var elem_hint: int
55 |
56 | if elem_type_hint.is_valid_int():
57 | elem_type = elem_type_hint.to_int()
58 | type_hint_prefixes += "<%s>:" % type_string(elem_type)
59 | else:
60 | if elem_type_hint.count("/") != 1:
61 | push_error("Invalid PROPERTY_HINT_TYPE_STRING format.")
62 | elem_type = elem_type_hint.get_slice("/", 0).to_int()
63 | elem_hint = elem_type_hint.get_slice("/", 1).to_int()
64 | type_hint_prefixes += "<%s>/<%s>:" % [
65 | type_string(elem_type),
66 | get_property_hint_name(elem_hint).trim_prefix("PROPERTY_HINT_"),
67 | ]
68 |
69 | if elem_type < TYPE_ARRAY or hint_string.is_empty():
70 | break
71 |
72 | return type_hint_prefixes + hint_string
73 |
74 | return property.hint_string
75 |
76 |
77 | static func print_property_extended_info(property: Dictionary, base: Object = null, is_static: bool = false) -> void:
78 | print(get_property_signature(property, base, is_static))
79 | print(' hint=%s hint_string="%s" usage=%s class_name=&"%s"' % [
80 | get_property_hint_name(property.hint).trim_prefix("PROPERTY_HINT_"),
81 | get_human_readable_hint_string(property).c_escape(),
82 | get_property_usage_string(property.usage).replace("PROPERTY_USAGE_", ""),
83 | property.class_name.c_escape(),
84 | ])
85 |
86 |
87 | static func get_method_signature(method: Dictionary, is_signal: bool = false) -> String:
88 | var result: String = ""
89 | if method.flags & METHOD_FLAG_STATIC:
90 | result += "static "
91 | result += ("signal " if is_signal else "func ") + method.name + "("
92 |
93 | var args: Array[Dictionary] = method.args
94 | var default_args: Array = method.default_args
95 | var mandatory_argc: int = args.size() - default_args.size()
96 | for i in args.size():
97 | if i > 0:
98 | result += ", "
99 | var arg: Dictionary = args[i]
100 | result += arg.name + ": " + get_type(arg)
101 | if i >= mandatory_argc:
102 | result += " = " + var_to_str(default_args[i - mandatory_argc])
103 |
104 | result += ")"
105 | if is_signal:
106 | if get_type(method.return, true) != "void":
107 | printerr("Signal return type must be `void`.")
108 | else:
109 | result += " -> " + get_type(method.return, true)
110 | return result
111 |
112 |
113 | static func get_property_hint_name(hint: PropertyHint) -> String:
114 | match hint:
115 | PROPERTY_HINT_NONE:
116 | return "PROPERTY_HINT_NONE"
117 | PROPERTY_HINT_RANGE:
118 | return "PROPERTY_HINT_RANGE"
119 | PROPERTY_HINT_ENUM:
120 | return "PROPERTY_HINT_ENUM"
121 | PROPERTY_HINT_ENUM_SUGGESTION:
122 | return "PROPERTY_HINT_ENUM_SUGGESTION"
123 | PROPERTY_HINT_EXP_EASING:
124 | return "PROPERTY_HINT_EXP_EASING"
125 | PROPERTY_HINT_LINK:
126 | return "PROPERTY_HINT_LINK"
127 | PROPERTY_HINT_FLAGS:
128 | return "PROPERTY_HINT_FLAGS"
129 | PROPERTY_HINT_LAYERS_2D_RENDER:
130 | return "PROPERTY_HINT_LAYERS_2D_RENDER"
131 | PROPERTY_HINT_LAYERS_2D_PHYSICS:
132 | return "PROPERTY_HINT_LAYERS_2D_PHYSICS"
133 | PROPERTY_HINT_LAYERS_2D_NAVIGATION:
134 | return "PROPERTY_HINT_LAYERS_2D_NAVIGATION"
135 | PROPERTY_HINT_LAYERS_3D_RENDER:
136 | return "PROPERTY_HINT_LAYERS_3D_RENDER"
137 | PROPERTY_HINT_LAYERS_3D_PHYSICS:
138 | return "PROPERTY_HINT_LAYERS_3D_PHYSICS"
139 | PROPERTY_HINT_LAYERS_3D_NAVIGATION:
140 | return "PROPERTY_HINT_LAYERS_3D_NAVIGATION"
141 | PROPERTY_HINT_LAYERS_AVOIDANCE:
142 | return "PROPERTY_HINT_LAYERS_AVOIDANCE"
143 | PROPERTY_HINT_FILE:
144 | return "PROPERTY_HINT_FILE"
145 | PROPERTY_HINT_DIR:
146 | return "PROPERTY_HINT_DIR"
147 | PROPERTY_HINT_GLOBAL_FILE:
148 | return "PROPERTY_HINT_GLOBAL_FILE"
149 | PROPERTY_HINT_GLOBAL_DIR:
150 | return "PROPERTY_HINT_GLOBAL_DIR"
151 | PROPERTY_HINT_RESOURCE_TYPE:
152 | return "PROPERTY_HINT_RESOURCE_TYPE"
153 | PROPERTY_HINT_MULTILINE_TEXT:
154 | return "PROPERTY_HINT_MULTILINE_TEXT"
155 | PROPERTY_HINT_EXPRESSION:
156 | return "PROPERTY_HINT_EXPRESSION"
157 | PROPERTY_HINT_PLACEHOLDER_TEXT:
158 | return "PROPERTY_HINT_PLACEHOLDER_TEXT"
159 | PROPERTY_HINT_COLOR_NO_ALPHA:
160 | return "PROPERTY_HINT_COLOR_NO_ALPHA"
161 | PROPERTY_HINT_OBJECT_ID:
162 | return "PROPERTY_HINT_OBJECT_ID"
163 | PROPERTY_HINT_TYPE_STRING:
164 | return "PROPERTY_HINT_TYPE_STRING"
165 | PROPERTY_HINT_NODE_PATH_TO_EDITED_NODE:
166 | return "PROPERTY_HINT_NODE_PATH_TO_EDITED_NODE"
167 | PROPERTY_HINT_OBJECT_TOO_BIG:
168 | return "PROPERTY_HINT_OBJECT_TOO_BIG"
169 | PROPERTY_HINT_NODE_PATH_VALID_TYPES:
170 | return "PROPERTY_HINT_NODE_PATH_VALID_TYPES"
171 | PROPERTY_HINT_SAVE_FILE:
172 | return "PROPERTY_HINT_SAVE_FILE"
173 | PROPERTY_HINT_GLOBAL_SAVE_FILE:
174 | return "PROPERTY_HINT_GLOBAL_SAVE_FILE"
175 | PROPERTY_HINT_INT_IS_OBJECTID:
176 | return "PROPERTY_HINT_INT_IS_OBJECTID"
177 | PROPERTY_HINT_INT_IS_POINTER:
178 | return "PROPERTY_HINT_INT_IS_POINTER"
179 | PROPERTY_HINT_ARRAY_TYPE:
180 | return "PROPERTY_HINT_ARRAY_TYPE"
181 | PROPERTY_HINT_LOCALE_ID:
182 | return "PROPERTY_HINT_LOCALE_ID"
183 | PROPERTY_HINT_LOCALIZABLE_STRING:
184 | return "PROPERTY_HINT_LOCALIZABLE_STRING"
185 | PROPERTY_HINT_NODE_TYPE:
186 | return "PROPERTY_HINT_NODE_TYPE"
187 | PROPERTY_HINT_HIDE_QUATERNION_EDIT:
188 | return "PROPERTY_HINT_HIDE_QUATERNION_EDIT"
189 | PROPERTY_HINT_PASSWORD:
190 | return "PROPERTY_HINT_PASSWORD"
191 | push_error("Argument `hint` is invalid. Use `PROPERTY_HINT_*` constants.")
192 | return ""
193 |
194 |
195 | static func get_property_usage_string(usage: int) -> String:
196 | if usage == PROPERTY_USAGE_NONE:
197 | return "PROPERTY_USAGE_NONE"
198 |
199 | const FLAGS: Array[Array] = [
200 | [PROPERTY_USAGE_STORAGE, "PROPERTY_USAGE_STORAGE"],
201 | [PROPERTY_USAGE_EDITOR, "PROPERTY_USAGE_EDITOR"],
202 | [PROPERTY_USAGE_INTERNAL, "PROPERTY_USAGE_INTERNAL"],
203 | [PROPERTY_USAGE_CHECKABLE, "PROPERTY_USAGE_CHECKABLE"],
204 | [PROPERTY_USAGE_CHECKED, "PROPERTY_USAGE_CHECKED"],
205 | [PROPERTY_USAGE_GROUP, "PROPERTY_USAGE_GROUP"],
206 | [PROPERTY_USAGE_CATEGORY, "PROPERTY_USAGE_CATEGORY"],
207 | [PROPERTY_USAGE_SUBGROUP, "PROPERTY_USAGE_SUBGROUP"],
208 | [PROPERTY_USAGE_CLASS_IS_BITFIELD, "PROPERTY_USAGE_CLASS_IS_BITFIELD"],
209 | [PROPERTY_USAGE_NO_INSTANCE_STATE, "PROPERTY_USAGE_NO_INSTANCE_STATE"],
210 | [PROPERTY_USAGE_RESTART_IF_CHANGED, "PROPERTY_USAGE_RESTART_IF_CHANGED"],
211 | [PROPERTY_USAGE_SCRIPT_VARIABLE, "PROPERTY_USAGE_SCRIPT_VARIABLE"],
212 | [PROPERTY_USAGE_STORE_IF_NULL, "PROPERTY_USAGE_STORE_IF_NULL"],
213 | [PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED, "PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED"],
214 | [PROPERTY_USAGE_SCRIPT_DEFAULT_VALUE, "PROPERTY_USAGE_SCRIPT_DEFAULT_VALUE"],
215 | [PROPERTY_USAGE_CLASS_IS_ENUM, "PROPERTY_USAGE_CLASS_IS_ENUM"],
216 | [PROPERTY_USAGE_NIL_IS_VARIANT, "PROPERTY_USAGE_NIL_IS_VARIANT"],
217 | [PROPERTY_USAGE_ARRAY, "PROPERTY_USAGE_ARRAY"],
218 | [PROPERTY_USAGE_ALWAYS_DUPLICATE, "PROPERTY_USAGE_ALWAYS_DUPLICATE"],
219 | [PROPERTY_USAGE_NEVER_DUPLICATE, "PROPERTY_USAGE_NEVER_DUPLICATE"],
220 | [PROPERTY_USAGE_HIGH_END_GFX, "PROPERTY_USAGE_HIGH_END_GFX"],
221 | [PROPERTY_USAGE_NODE_PATH_FROM_SCENE_ROOT, "PROPERTY_USAGE_NODE_PATH_FROM_SCENE_ROOT"],
222 | [PROPERTY_USAGE_RESOURCE_NOT_PERSISTENT, "PROPERTY_USAGE_RESOURCE_NOT_PERSISTENT"],
223 | [PROPERTY_USAGE_KEYING_INCREMENTS, "PROPERTY_USAGE_KEYING_INCREMENTS"],
224 | [PROPERTY_USAGE_DEFERRED_SET_RESOURCE, "PROPERTY_USAGE_DEFERRED_SET_RESOURCE"],
225 | [PROPERTY_USAGE_EDITOR_INSTANTIATE_OBJECT, "PROPERTY_USAGE_EDITOR_INSTANTIATE_OBJECT"],
226 | [PROPERTY_USAGE_EDITOR_BASIC_SETTING, "PROPERTY_USAGE_EDITOR_BASIC_SETTING"],
227 | [PROPERTY_USAGE_READ_ONLY, "PROPERTY_USAGE_READ_ONLY"],
228 | [PROPERTY_USAGE_SECRET, "PROPERTY_USAGE_SECRET"],
229 | ]
230 |
231 | var result: String = ""
232 |
233 | if (usage & PROPERTY_USAGE_DEFAULT) == PROPERTY_USAGE_DEFAULT:
234 | result += "PROPERTY_USAGE_DEFAULT|"
235 | usage &= ~PROPERTY_USAGE_DEFAULT
236 |
237 | for flag in FLAGS:
238 | if usage & flag[0]:
239 | result += flag[1] + "|"
240 | usage &= ~flag[0]
241 |
242 | if usage != PROPERTY_USAGE_NONE:
243 | push_error("Argument `usage` is invalid. Use `PROPERTY_USAGE_*` constants.")
244 | return ""
245 |
246 | return result.left(-1)
247 |
--------------------------------------------------------------------------------
/Tests/CodeEditorUITests/CodeEditorUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import CodeEditorUI
3 |
4 | final class CodeEditorUITests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------