├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── CodeEditorUI │ ├── CodeEditorShell.swift │ ├── CodeEditorState.swift │ ├── CodeEditorView.swift │ ├── CompletionEntry.swift │ ├── CompletionsDisplayView.swift │ ├── DiagnosticDetails.swift │ ├── EditedItem.swift │ ├── EditorTab.swift │ ├── GotoLineView.swift │ ├── HostServices.swift │ ├── HostedItem.swift │ ├── HtmlItem.swift │ ├── PathBrowser.swift │ ├── SwiftUIHostedItem.swift │ └── utils.notest.gd └── Tests └── CodeEditorUITests └── CodeEditorUITests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Package.resolved 10 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | # - accessibility_label_for_image 3 | # - accessibility_trait_for_button 4 | # - anonymous_argument_in_multiline_closure 5 | # - anyobject_protocol 6 | # - array_init 7 | # - attributes 8 | - balanced_xctest_lifecycle 9 | # - blanket_disable_command 10 | - block_based_kvo 11 | - capture_variable 12 | - class_delegate_protocol 13 | # - closing_brace 14 | # - closure_body_length 15 | # - closure_end_indentation 16 | # - closure_parameter_position 17 | # - closure_spacing 18 | # - collection_alignment 19 | - colon 20 | # - comma 21 | - comma_inheritance 22 | # - comment_spacing 23 | - compiler_protocol_init 24 | - computed_accessors_order 25 | # - conditional_returns_on_newline 26 | - contains_over_filter_count 27 | - contains_over_filter_is_empty 28 | # - contains_over_first_not_nil 29 | - contains_over_range_nil_comparison 30 | # - control_statement 31 | # - convenience_type 32 | - custom_rules 33 | # - cyclomatic_complexity 34 | - deployment_target 35 | # - direct_return 36 | - discarded_notification_center_observer 37 | - discouraged_assert 38 | - discouraged_direct_init 39 | - discouraged_none_name 40 | # - discouraged_object_literal 41 | # - discouraged_optional_boolean 42 | # - discouraged_optional_collection 43 | - duplicate_conditions 44 | - duplicate_enum_cases 45 | - duplicate_imports 46 | - duplicated_key_in_dictionary_literal 47 | - dynamic_inline 48 | - empty_collection_literal 49 | # - empty_count 50 | # - empty_enum_arguments 51 | - empty_parameters 52 | # - empty_parentheses_with_trailing_closure 53 | # - empty_string 54 | # - empty_xctest_method 55 | # - enum_case_associated_values_count 56 | - expiring_todo 57 | # - explicit_acl 58 | # - explicit_enum_raw_value 59 | # - explicit_init 60 | - explicit_self 61 | # - explicit_top_level_acl 62 | # - explicit_type_interface 63 | # - extension_access_modifier 64 | - fallthrough 65 | # - fatal_error_message 66 | - file_header 67 | # - file_length 68 | # - file_name 69 | - file_name_no_space 70 | # - file_types_order 71 | - final_test_case 72 | - first_where 73 | - flatmap_over_map_reduce 74 | # - for_where 75 | - force_cast 76 | - force_try 77 | - force_unwrapping 78 | # - function_body_length 79 | # - function_default_parameter_at_end 80 | # - function_parameter_count 81 | - generic_type_name 82 | - ibinspectable_in_extension 83 | - identical_operands 84 | # - identifier_name 85 | # - implicit_getter 86 | # - implicit_return 87 | - implicitly_unwrapped_optional 88 | - inclusive_language 89 | # - indentation_width 90 | # - inert_defer 91 | - invalid_swiftlint_command 92 | - is_disjoint 93 | # - joined_default_parameter 94 | # - large_tuple 95 | - last_where 96 | - leading_whitespace 97 | - legacy_cggeometry_functions 98 | - legacy_constant 99 | # - legacy_constructor 100 | - legacy_hashing 101 | - legacy_multiple 102 | - legacy_nsgeometry_functions 103 | # - legacy_objc_type 104 | - legacy_random 105 | # - let_var_whitespace 106 | # - line_length 107 | # - literal_expression_end_indentation 108 | # - local_doc_comment 109 | # - lower_acl_than_parent 110 | - mark 111 | # - missing_docs 112 | # - modifier_order 113 | # - multiline_arguments 114 | # - multiline_arguments_brackets 115 | # - multiline_function_chains 116 | # - multiline_literal_brackets 117 | - multiline_parameters 118 | # - multiline_parameters_brackets 119 | # - multiple_closures_with_trailing_closure 120 | # - nesting 121 | - nimble_operator 122 | # - no_extension_access_modifier 123 | - no_fallthrough_only 124 | # - no_grouping_extension 125 | # - no_magic_numbers 126 | # - no_space_in_method_call 127 | - non_optional_string_data_conversion 128 | - non_overridable_class_declaration 129 | - notification_center_detachment 130 | - ns_number_init_as_function_reference 131 | - nslocalizedstring_key 132 | - nslocalizedstring_require_bundle 133 | - nsobject_prefer_isequal 134 | # - number_separator 135 | - object_literal 136 | # - one_declaration_per_file 137 | # - opening_brace 138 | # - operator_usage_whitespace 139 | - operator_whitespace 140 | - optional_enum_case_matching 141 | # - orphaned_doc_comment 142 | - overridden_super_call 143 | - override_in_extension 144 | # - pattern_matching_keywords 145 | # - period_spacing 146 | - prefer_nimble 147 | # - prefer_self_in_static_references 148 | - prefer_self_type_over_type_of_self 149 | - prefer_zero_over_explicit_init 150 | # - prefixed_toplevel_constant 151 | - private_action 152 | - private_outlet 153 | - private_over_fileprivate 154 | - private_subject 155 | # - private_swiftui_state 156 | - private_unit_test 157 | - prohibited_interface_builder 158 | - prohibited_super_call 159 | - protocol_property_accessors_order 160 | - quick_discouraged_call 161 | - quick_discouraged_focused_test 162 | - quick_discouraged_pending_test 163 | - raw_value_for_camel_cased_codable_enum 164 | - reduce_boolean 165 | - reduce_into 166 | # - redundant_discardable_let 167 | # - redundant_nil_coalescing 168 | - redundant_objc_attribute 169 | # - redundant_optional_initialization 170 | # - redundant_self_in_closure 171 | - redundant_set_access_control 172 | - redundant_string_enum_value 173 | # - redundant_type_annotation 174 | - redundant_void_return 175 | # - required_deinit 176 | - required_enum_case 177 | # - return_arrow_whitespace 178 | # - return_value_from_void_function 179 | - self_binding 180 | - self_in_property_initialization 181 | - shorthand_argument 182 | - shorthand_operator 183 | # - shorthand_optional_binding 184 | - single_test_class 185 | # - sorted_enum_cases 186 | - sorted_first_last 187 | # - sorted_imports 188 | - statement_position 189 | - static_operator 190 | - static_over_final_class 191 | - strict_fileprivate 192 | - strong_iboutlet 193 | - superfluous_disable_command 194 | # - superfluous_else 195 | - switch_case_alignment 196 | # - switch_case_on_newline 197 | - syntactic_sugar 198 | - test_case_accessibility 199 | # - todo 200 | # - toggle_bool 201 | # - trailing_closure 202 | # - trailing_comma 203 | # - trailing_newline 204 | # - trailing_semicolon 205 | - trailing_whitespace 206 | # - type_body_length 207 | # - type_contents_order 208 | # - type_name 209 | - typesafe_array_init 210 | - unavailable_condition 211 | # - unavailable_function 212 | - unhandled_throwing_task 213 | # - unneeded_break_in_switch 214 | - unneeded_override 215 | # - unneeded_parentheses_in_closure_argument 216 | - unneeded_synthesized_initializer 217 | - unowned_variable_capture 218 | # - untyped_error_in_catch 219 | # - unused_capture_list 220 | # - unused_closure_parameter 221 | - unused_control_flow_label 222 | - unused_declaration 223 | - unused_enumerated 224 | - unused_import 225 | - unused_optional_binding 226 | - unused_setter_value 227 | - valid_ibinspectable 228 | # - vertical_parameter_alignment 229 | # - vertical_parameter_alignment_on_call 230 | - vertical_whitespace 231 | # - vertical_whitespace_between_cases 232 | # - vertical_whitespace_closing_braces 233 | # - vertical_whitespace_opening_braces 234 | - void_function_in_ternary 235 | # - void_return 236 | - weak_delegate 237 | - xct_specific_matcher 238 | - xctfail_message 239 | - yoda_condition 240 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-2025 Xibbon, Inc (https://xibbon.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CodeEditorUI", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "CodeEditorUI", 13 | targets: ["CodeEditorUI"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/xibbon/RunestoneUI", branch: "main"), 17 | .package(url: "https://github.com/xibbon/Runestone", branch: "main"), 18 | .package(url: "https://github.com/xibbon/MiniTreeSitterLanguages", branch: "main"), 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package, defining a module or a test suite. 22 | // Targets can depend on other targets in this package and products from dependencies. 23 | .target( 24 | name: "CodeEditorUI", 25 | dependencies: [ 26 | "RunestoneUI", 27 | "Runestone", 28 | .product(name: "TreeSitterGDScriptRunestone", package: "MiniTreeSitterLanguages"), 29 | .product(name: "TreeSitterJSON", package: "MiniTreeSitterLanguages"), 30 | .product(name: "TreeSitterJSONRunestone", package: "MiniTreeSitterLanguages"), 31 | .product(name: "TreeSitterMarkdownRunestone", package: "MiniTreeSitterLanguages"), 32 | .product(name: "TreeSitterGLSLRunestone", package: "MiniTreeSitterLanguages"), 33 | ]), 34 | .testTarget( 35 | name: "CodeEditorUITests", 36 | dependencies: ["CodeEditorUI"]), 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This contains the UI for a CodeEditor experience on the iPad, not necessarily tied to Xogot or XogotUI, 2 | so we can develop that experience indepedently. 3 | -------------------------------------------------------------------------------- /Sources/CodeEditorUI/CodeEditorShell.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Runestone 3 | import TreeSitter 4 | import RunestoneUI 5 | import TreeSitterGDScriptRunestone 6 | 7 | /// This is the host for all of the coding needs that we have 8 | public struct CodeEditorShell: View { 9 | @State var state: CodeEditorState 10 | @State var showDiagnosticDetails = false 11 | @FocusState var isFocused: Bool 12 | let emptyContent: () -> EmptyContent 13 | let urlLoader: (URL) -> String? 14 | 15 | /// Creates the CodeEditorShell 16 | /// - Parameters: 17 | /// - state: The state used to control this CodeEditorShell 18 | /// - urlLoader: This should load a URL, and upon successful completion, it can return an anchor to scroll to, or nil otherwise 19 | /// - emptyView: A view to show if there are no tabs open 20 | public init (state: CodeEditorState, urlLoader: @escaping (URL) -> String?, @ViewBuilder emptyView: @escaping ()->EmptyContent) { 21 | self._state = State(initialValue: state) 22 | self.emptyContent = emptyView 23 | self.urlLoader = urlLoader 24 | } 25 | 26 | @ViewBuilder 27 | func diagnosticBlurb (editedItem: EditedItem) -> some View { 28 | HStack { 29 | if let warnings = editedItem.warnings { 30 | Button (action: { showDiagnosticDetails.toggle () }) { 31 | HStack (spacing: 4){ 32 | Image (systemName: "exclamationmark.triangle.fill") 33 | Text ("\(warnings.count)") 34 | }.foregroundStyle(Color.orange) 35 | } 36 | } 37 | if let errors = editedItem.errors { 38 | Button (action: { showDiagnosticDetails.toggle() }) { 39 | HStack (spacing: 4) { 40 | Image (systemName: "xmark.circle.fill") 41 | Text ("\(errors.count)") 42 | }.foregroundStyle(Color.red) 43 | } 44 | } 45 | if editedItem.warnings != nil || editedItem.errors != nil { 46 | Button (action: { withAnimation { showDiagnosticDetails.toggle() } }) { 47 | HStack (spacing: 4) { 48 | Image (systemName: "chevron.right") 49 | .rotationEffect(showDiagnosticDetails ? Angle (degrees: 90) : Angle(degrees: 0)) 50 | } 51 | } 52 | .foregroundStyle(.secondary) 53 | .padding (.horizontal, 8) 54 | } 55 | } 56 | } 57 | 58 | func focusEditor() { 59 | DispatchQueue.main.asyncAfter(deadline: .now()+0.3) { 60 | isFocused = true 61 | } 62 | } 63 | 64 | @State var disclosureControlWidth: CGFloat = 0 65 | @State var errorWindowWidth: CGFloat = 0 66 | 67 | @ViewBuilder 68 | var editorContent: some View { 69 | if let currentIdx = state.currentEditor, currentIdx >= 0, currentIdx < state.openFiles.count { 70 | let current = state.openFiles [currentIdx] 71 | ZStack { 72 | ForEach (state.openFiles) { file in 73 | Group { 74 | if let editedItem = file as? EditedItem { 75 | VStack(spacing: 0) { 76 | if state.showPathBrowser { 77 | PathBrowser (item: editedItem) 78 | .environment(state) 79 | .padding(.bottom, 0) 80 | .padding(.horizontal, 10) 81 | Divider() 82 | } 83 | ZStack(alignment: .top) { 84 | CodeEditorView( 85 | state: state, 86 | item: editedItem, 87 | contents: Binding(get: { 88 | editedItem.content 89 | }, set: { newV in 90 | editedItem.content = newV 91 | }) 92 | ) 93 | .focusable() 94 | .id(file) 95 | .focused($isFocused, equals: true) 96 | if state.showGotoLine { 97 | GotoLineView(showing: $state.showGotoLine) { newLine in 98 | editedItem.commands.requestGoto(line: newLine-1) 99 | DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { 100 | editedItem.commands.becomeFirstResponder() 101 | } 102 | } 103 | } 104 | } 105 | .zIndex(1) 106 | 107 | if showDiagnosticDetails || editedItem.errors != nil || editedItem.warnings != nil { 108 | Divider() 109 | } 110 | if showDiagnosticDetails, 111 | ((editedItem.errors?.count ?? 0) > 0 || (editedItem.warnings?.count ?? 0) > 0) { 112 | ZStack(alignment: .topLeading) { 113 | DiagnosticDetailsView(errors: editedItem.errors, warnings: editedItem.warnings, item: editedItem, maxFirstLine: errorWindowWidth-disclosureControlWidth) 114 | HStack { 115 | Spacer() 116 | diagnosticBlurb (editedItem: editedItem) 117 | .padding(.top, 8) 118 | .padding(.bottom, 10) 119 | .padding(.horizontal, 10) 120 | .font(.footnote) 121 | .background(Color(.systemBackground)) 122 | .onGeometryChange(for: CGFloat.self) { 123 | $0.size.width 124 | } action: { 125 | disclosureControlWidth = $0 126 | } 127 | } 128 | } 129 | .onGeometryChange(for: CGFloat.self) { 130 | $0.size.width 131 | } action: { 132 | errorWindowWidth = $0 133 | } 134 | .frame(maxHeight: 120) 135 | } else if let hint = editedItem.hint { 136 | HStack { 137 | Button (action: { showDiagnosticDetails.toggle()}) { 138 | ShowHint(text: hint) 139 | .fontDesign(.monospaced) 140 | .lineLimit(1) 141 | }.buttonStyle(.plain) 142 | Spacer () 143 | diagnosticBlurb (editedItem: editedItem) 144 | } 145 | .padding(.top, 8) 146 | .padding(.bottom, 10) 147 | .padding(.horizontal, 10) 148 | .font(.footnote) 149 | } else if let firstError = editedItem.errors?.first ?? editedItem.warnings?.first { 150 | HStack { 151 | Button (action: { showDiagnosticDetails.toggle()}) { 152 | ShowIssue (issue: firstError) 153 | .fontDesign(.monospaced) 154 | .lineLimit(1) 155 | }.buttonStyle(.plain) 156 | Spacer () 157 | diagnosticBlurb (editedItem: editedItem) 158 | } 159 | .padding(.top, 8) 160 | .padding(.bottom, 10) 161 | .padding(.horizontal, 10) 162 | .font(.footnote) 163 | } 164 | } 165 | } else if let htmlItem = file as? HtmlItem { 166 | WebView(text: htmlItem.content, 167 | anchor: htmlItem.anchor, 168 | obj: htmlItem, 169 | load: urlLoader) 170 | Spacer() 171 | } else if let swifuiItem = file as? SwiftUIHostedItem { 172 | swifuiItem.view() 173 | } 174 | } 175 | .opacity(current.id == file.id ? 1 : 0) 176 | } 177 | } 178 | } else { 179 | emptyContent() 180 | } 181 | } 182 | 183 | var fileMenu: some View { 184 | Menu { 185 | Button(action: { 186 | state.requestFileOpen(title: "Open Shader", path: "res://") { files in 187 | guard let file = files.first else { return } 188 | 189 | state.requestOpen(path: file) 190 | } 191 | }) { 192 | Text("Open Shader") 193 | } 194 | Button(action: { 195 | state.saveCurrentFile() 196 | }) { 197 | Text("Save Shader") 198 | } 199 | Button(action: { 200 | state.saveFileAs() 201 | }) { 202 | Text("Save Shader As...") 203 | } 204 | } label: { 205 | Text("File") 206 | } 207 | } 208 | 209 | public var body: some View { 210 | VStack { 211 | HStack { 212 | if state.showFileMenu, state.openFiles.count > 0 { 213 | fileMenu 214 | } 215 | EditorTabs(selected: $state.currentEditor, items: $state.openFiles, closeRequest: { idx in 216 | state.attemptClose (idx) 217 | }) 218 | } 219 | .alert("Error", isPresented: Binding(get: { state.saveError }, set: { newV in state.saveError = newV })) { 220 | Button ("Retry") { 221 | state.saveError = false 222 | DispatchQueue.main.async { 223 | state.attemptClose(state.saveIdx) 224 | } 225 | } 226 | Button ("Cancel") { 227 | state.saveError = false 228 | } 229 | Button ("Ignore") { 230 | state.closeFile (state.saveIdx) 231 | state.saveError = false 232 | } 233 | } message: { 234 | Text (state.saveErrorMessage) 235 | } 236 | 237 | editorContent 238 | .background { 239 | RoundedRectangle(cornerRadius: 11) 240 | .fill(Color(uiColor: .systemBackground)) 241 | .stroke(Color(uiColor: .systemGray5)) 242 | } 243 | .clipShape(RoundedRectangle (cornerRadius: 11)) 244 | } 245 | //.background { Color (uiColor: .systemBackground) } 246 | 247 | .padding(8) 248 | } 249 | } 250 | 251 | /// This shows a single line from the hint string, we need to figure out a good way of showing all the lines 252 | struct ShowHint: View { 253 | let str: AttributedString 254 | 255 | init(text: String) { 256 | var str = AttributedString() 257 | let lines = text.split(separator: "\n") 258 | 259 | let ranges = lines[0].ranges(of: "\u{ffff}") 260 | if ranges.count == 2 { 261 | var highlighted = AttributedString(text[ranges[0].upperBound.. ()) { 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: "Hello

hack") 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 | --------------------------------------------------------------------------------