├── addons └── .gitkeep ├── project-manager ├── .gitkeep └── inspect │ ├── helpers.gd │ └── inspect_plugin.gd ├── .gitattributes ├── .gitignore ├── _internal ├── injector │ ├── plugin.cfg │ └── plugin.gd ├── folder_colors │ ├── plugin.cfg │ └── plugin.gd ├── path_processor │ ├── plugin.cfg │ └── plugin.gd ├── runner.gd ├── plugins_enabler.gd ├── testing │ └── node.tscn └── loader.gd ├── editor-only └── included │ ├── paths.gd │ ├── gdx.gd │ └── addon_import_plugin.gd ├── .editorconfig ├── project.godot ├── icon.svg ├── icon.svg.import ├── GDX.md └── README.md /addons/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project-manager/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | .processed/ 4 | android/ 5 | addons/* 6 | !.gitkeep 7 | editor-only/testing 8 | -------------------------------------------------------------------------------- /_internal/injector/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="global-project-plugin" 4 | description="" 5 | author="dugramen" 6 | version="" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /_internal/folder_colors/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="global-project-plugin" 4 | description="" 5 | author="dugramen" 6 | version="" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /_internal/path_processor/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="global-project-plugin" 4 | description="" 5 | author="dugramen" 6 | version="" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /_internal/runner.gd: -------------------------------------------------------------------------------- 1 | #@tool 2 | static var path := "_PATH_TO_REPLACE_" 3 | static func _static_init() -> void: 4 | var file = load(path) 5 | if file is GDScript and "init_extensions" in file: 6 | file.init_extensions(path, file) 7 | -------------------------------------------------------------------------------- /editor-only/included/paths.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | static var global := path_preload("res://").trim_suffix(".processed/") 4 | static var processed := path_preload("res://") 5 | static var local := ProjectSettings.globalize_path("res://") 6 | 7 | static func path_preload(s: String) -> String: 8 | return s 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | # Matches multiple files with brace expansion notation 8 | [*.gd] 9 | charset = utf-8 10 | indent_style = tab 11 | indent_size = 4 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /_internal/plugins_enabler.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | static func _static_init(): 4 | print("enabling internal plugin") 5 | for plugin in [ 6 | "folder_colors", 7 | "injector", 8 | "path_processor" 9 | ]: 10 | var path := plugin_path(plugin) 11 | if EditorInterface.is_plugin_enabled(path): 12 | EditorInterface.set_plugin_enabled(path, false) 13 | EditorInterface.set_plugin_enabled(path, true) 14 | 15 | static func plugin_path(s: String) -> String: 16 | return "res://_internal".path_join(s).path_join("plugin.cfg") 17 | -------------------------------------------------------------------------------- /_internal/injector/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | extends EditorPlugin 4 | 5 | var scr: GDScript = preload("res://_internal/runner.gd") 6 | 7 | func _enter_tree() -> void: 8 | print("static init") 9 | var settings := EditorInterface.get_editor_settings() 10 | var new_scr := GDScript.new() 11 | #print('gp path ', ProjectSettings.globalize_path("res://loader.gd")) 12 | new_scr.source_code = scr.source_code\ 13 | .trim_prefix("#")\ 14 | .replace("_PATH_TO_REPLACE_", ProjectSettings.globalize_path("res://_internal/loader.gd")) 15 | settings.set_setting("portable_plugins/injected_script", new_scr) 16 | new_scr.reload() 17 | -------------------------------------------------------------------------------- /project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=5 10 | 11 | [application] 12 | 13 | config/name="global-project" 14 | config/version="1.1" 15 | run/main_scene="res://_internal/testing/node.tscn" 16 | config/features=PackedStringArray("4.3", "GL Compatibility") 17 | config/icon="res://icon.svg" 18 | 19 | [editor_plugins] 20 | 21 | enabled=PackedStringArray("res://_internal/folder_colors/plugin.cfg", "res://_internal/injector/plugin.cfg", "res://_internal/path_processor/plugin.cfg") 22 | 23 | [rendering] 24 | 25 | renderer/rendering_method="gl_compatibility" 26 | renderer/rendering_method.mobile="gl_compatibility" 27 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icon.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://b868eeotkql50" 6 | path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://icon.svg" 14 | dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /_internal/testing/node.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://bbr6u2kefuco4"] 2 | 3 | [sub_resource type="GDScript" id="GDScript_l8f1h"] 4 | script/source = "extends Control 5 | 6 | #var farm := preload(\"res://editor-only/testing/farm.tscn\") 7 | 8 | func _ready() -> void: 9 | pass 10 | #print(ResourceLoader.get_dependencies(\"res://node.tscn\")) 11 | #var deps := ResourceLoader.get_dependencies(\"res://editor-only/testing/farm.tscn\") 12 | #var content := FileAccess.get_file_as_string(\"res://editor-only/testing/farm.tscn\") 13 | #var process_path := ProjectSettings.globalize_path(\"res://.processed\") 14 | # 15 | #var i := 0 16 | #while i >= 0: 17 | #i = content.find(\"\\n[ext_resource \", i + 1) 18 | #if i < 0: break 19 | # 20 | #var path_substr := ' path=\"' 21 | #var path_start := content.find(path_substr, i) + path_substr.length() 22 | #var path_end := content.find('\"', path_start) 23 | # 24 | #var end = content.find(\"]\\n\", i + 1) 25 | #var slice := content.substr(path_start, path_end - path_start) 26 | #if slice.begins_with(\"res://\"): 27 | #slice = slice.trim_prefix(\"res://\") 28 | #slice = process_path.path_join(slice) 29 | #content = content.erase(path_start, path_end - path_start) 30 | #content = content.insert(path_start, slice) 31 | #i = path_start + slice.length() 32 | #print(content) 33 | " 34 | 35 | [node name="Node" type="Control"] 36 | layout_mode = 3 37 | anchors_preset = 0 38 | script = SubResource("GDScript_l8f1h") 39 | -------------------------------------------------------------------------------- /project-manager/inspect/helpers.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | static var engine_root: Node = Engine.get_main_loop().root 4 | static var path_cache := {} 5 | 6 | static func extract_node(arr: Array, root := engine_root, include_internal := true, use_cache := true) -> Node: 7 | var current_node := root 8 | var next_node: Node = null 9 | 10 | if use_cache and arr in path_cache: 11 | return path_cache[arr] 12 | 13 | for item in arr: 14 | next_node = null 15 | if item is int: 16 | next_node = current_node.get_child(item, include_internal) 17 | elif item is String: 18 | for child in current_node.get_children(include_internal): 19 | if child.name.match(item): 20 | next_node = child 21 | break 22 | elif item is Array: 23 | var string := "" 24 | var index := 0 25 | for a in item: 26 | if a is String: 27 | string = a 28 | elif a is int: 29 | index = a 30 | if string.is_empty(): 31 | return null 32 | var count := 0 33 | for child in current_node.get_children(include_internal): 34 | if child.name.match(string): 35 | if count == index: 36 | next_node = child 37 | break 38 | count += 1 39 | if next_node == null: 40 | return null 41 | else: 42 | current_node = next_node 43 | 44 | if use_cache: 45 | path_cache[arr] = current_node 46 | 47 | return current_node 48 | 49 | static func create_project(path: String) -> String: 50 | if !path.ends_with("project.godot"): 51 | path = path.path_join("project.godot") 52 | if FileAccess.file_exists(path): 53 | return path 54 | DirAccess.make_dir_recursive_absolute(path.get_base_dir()) 55 | var cfg := ConfigFile.new() 56 | cfg.save(path) 57 | return path 58 | 59 | #static func cli(): 60 | #OS.create_instance(["C:/poo", "-e", "--editor"]) 61 | -------------------------------------------------------------------------------- /_internal/folder_colors/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | var menu: PopupMenu 5 | 6 | func _enter_tree() -> void: 7 | #print("folder plugin entered") 8 | modify_folder_color_text() 9 | 10 | func _exit_tree() -> void: 11 | #print("folder plugin exited") 12 | if menu: 13 | if menu.about_to_popup.is_connected(handle_menu_popup): 14 | #print('disconnecting menu') 15 | menu.about_to_popup.disconnect(handle_menu_popup) 16 | 17 | func modify_folder_color_text(): 18 | var dock := EditorInterface.get_file_system_dock() 19 | menu = dock.get_child(2, true) 20 | if menu: 21 | #print(menu) 22 | #print(menu.item_count) 23 | #print(menu.get_children(true)) 24 | #for i in menu.item_count: 25 | #print(menu.get_item_text(i)) 26 | menu.about_to_popup.connect(handle_menu_popup) 27 | 28 | func handle_menu_popup(): 29 | var color_menu = menu.get_child(-1, true) 30 | if color_menu is PopupMenu: 31 | if color_menu.item_count > 2: 32 | if color_menu.get_item_text(2).begins_with("Red"): 33 | var renames := { 34 | "Default": " 35 | No automatic behavior. 36 | Import these via the Addon Importer popup. 37 | 38 | It will popup automatically once per project. 39 | After that it's available at: 40 | Project > Tools > Addon Importer 41 | ", 42 | "": "", 43 | "Red --- Refresh": " 44 | Red addons are synced exactly as they appear in the global project. 45 | 46 | On load, they are deleted, and then copied over again. 47 | This means if addons are no longer Red in the global project, 48 | they will no longer exist in your other projects. 49 | 50 | This is ideal if you're developing & testing your own addons locally. 51 | ", 52 | "Orange --- Refresh Versioned": " 53 | This is recommended for asset store addons. 54 | 55 | Only when the version in plugin.cfg has changed, 56 | the addons are deleted, then copied over. 57 | Addons that are no longer Orange will also be deleted. 58 | 59 | This method makes it so files aren't copied everytime. 60 | ", 61 | "Yellow --- Update": " 62 | This is the same behavior as my old 'globalize-plugins' addon. 63 | 64 | On load, all yellow plugins are copied over. Nothing is deleted. 65 | So folders that are no longer yellow will still remain. 66 | If the addon's file structure / naming change, the old files will remain. 67 | 68 | Some addons store user data / preferences within its directory. 69 | Red and Orange addons will overwrite those, but Yellow addons won't. 70 | ", 71 | "Green --- Update Versioned": " 72 | This is the same as Yellow, but only copying when the version has changed. 73 | 74 | No files or directories will be removed. 75 | Addons whose colors have changed or were deleted will remain in projects. 76 | ", 77 | } 78 | var i := 0 79 | for key in renames: 80 | color_menu.set_item_text(i, key) 81 | color_menu.set_item_tooltip(i, renames[key]) 82 | i += 1 83 | 84 | #for i in color_menu.item_count: 85 | #var text = color_menu.get_item_text(i) 86 | #print(text) 87 | #color_menu.set_item_text(i, text + " - " + str(i)) 88 | -------------------------------------------------------------------------------- /_internal/loader.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | #class_name Loader 4 | 5 | static var global_path := "" 6 | static var local_path := "" 7 | static var editor_plugin_holder: Node = null 8 | 9 | static func init_extensions(loader_path: String, this_file: GDScript) -> void: 10 | #load(loader_path).take_over_path("res://loader.gd") 11 | #return 12 | global_path = loader_path.trim_suffix("_internal/loader.gd") 13 | print('running loader ', global_path) 14 | var main_loop := Engine.get_main_loop() 15 | if main_loop is SceneTree: 16 | main_loop.process_frame.connect(func(): 17 | if Engine.is_editor_hint(): 18 | #var path := loader_path.trim_suffix("loader.gd") 19 | var base_control := EditorInterface.get_base_control() 20 | var holder_name := "PortablePluginsHolder" 21 | editor_plugin_holder = base_control.get_node_or_null(holder_name) 22 | if editor_plugin_holder: 23 | for child in editor_plugin_holder.get_children(true): 24 | child.queue_free() 25 | else: 26 | editor_plugin_holder = Node.new() 27 | base_control.add_child(editor_plugin_holder) 28 | editor_plugin_holder.name = holder_name 29 | 30 | local_path = ProjectSettings.globalize_path("res://") 31 | 32 | if global_path == local_path: return 33 | 34 | var scripts: Array[Script] = [] 35 | var paths := [".processed/editor-only"] 36 | while !paths.is_empty(): 37 | var path: String = paths.pop_back() 38 | #print(path) 39 | for dir_name in DirAccess.get_directories_at(global_path.path_join(path)): 40 | paths.push_back(path.path_join(dir_name)) 41 | for file_name in DirAccess.get_files_at(global_path.path_join(path)): 42 | var file_path := global_path.path_join(path).path_join(file_name) 43 | #print(file_path) 44 | if file_name.ends_with("plugin.gd"): 45 | var file = load(file_path) 46 | if file is GDScript: 47 | instantiate_plugin(file) 48 | else: 49 | #print("project manager?") 50 | var root: Node = main_loop.root 51 | var paths := [global_path.path_join(".processed/project-manager")] 52 | while !paths.is_empty(): 53 | var path: String = paths.pop_back() as String 54 | for dir_name in DirAccess.get_directories_at(path): 55 | paths.push_back(path.path_join(dir_name)) 56 | for file_name in DirAccess.get_files_at(path): 57 | var file_path := path.path_join(file_name) 58 | if file_name.ends_with("plugin.gd"): 59 | var file = load(file_path) 60 | if file is GDScript: 61 | var instance: Object = file.new() 62 | #prints('project plugin ', file, instance) 63 | if instance is Node: 64 | root.add_child(instance) 65 | , CONNECT_ONE_SHOT) 66 | 67 | 68 | static func instantiate_plugin(file: GDScript): 69 | #file.reload(true) 70 | if file.get_instance_base_type() == "EditorPlugin": 71 | var plugin: EditorPlugin = file.new() 72 | if editor_plugin_holder: 73 | editor_plugin_holder.add_child(plugin) 74 | 75 | 76 | static func process_extension(file: GDScript, global_path := ProjectSettings.globalize_path("res://")): 77 | var new_source_code: String = file.source_code 78 | var index := 0 79 | var file_path := file.resource_path 80 | var folder_path := file_path.get_base_dir() 81 | 82 | while index > -1: 83 | index = new_source_code.find("preload(", index) 84 | if index == -1: 85 | break 86 | 87 | index += 8 88 | var string_char: String = new_source_code[index] 89 | if string_char != '"' and string_char != "'": 90 | continue 91 | 92 | var end: int = new_source_code.find(string_char, index + 1) 93 | var preload_path = new_source_code.substr(index + 1, end - index - 1) 94 | 95 | var splits: Array = preload_path.split("//", true, 1) 96 | var new_path := "" 97 | if splits.size() <= 1: 98 | new_path = folder_path + "/" + preload_path 99 | else: 100 | new_path = global_path + "/" + splits[1] 101 | new_source_code = new_source_code.erase(index + 1, end - index - 1) 102 | new_source_code = new_source_code.insert(index + 1, new_path) 103 | 104 | index += 1 105 | 106 | file.source_code = new_source_code 107 | -------------------------------------------------------------------------------- /editor-only/included/gdx.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | 3 | var _render_map := {} 4 | var _deletion_map := {} 5 | var _new_deletion_map := {} 6 | var _current_callable 7 | #static var hello := "World!" 8 | var confirmed_callable: Callable 9 | 10 | func get_this_render() -> Callable: 11 | return render.bind(_current_callable) 12 | 13 | func render(callable = null): 14 | if callable == null: 15 | callable = confirmed_callable 16 | else: 17 | confirmed_callable = callable 18 | var pc = _current_callable 19 | _current_callable = callable 20 | var tree: Array = _current_callable.call() 21 | var dm: Dictionary = _deletion_map.get_or_add(_current_callable, {}) 22 | var ndm: Dictionary = _new_deletion_map.get_or_add(_current_callable, {}) 23 | var node_from_previous_render = _render_map.get(_current_callable, null) 24 | var index := 0 25 | if node_from_previous_render is Node: 26 | node_from_previous_render = node_from_previous_render.get_parent() 27 | index = node_from_previous_render.get_index() 28 | var result = _build_element(_current_callable, tree, {index = index, node = node_from_previous_render}) 29 | if result is Array: 30 | if result.size() > 0: 31 | result = result[0] 32 | _render_map[_current_callable] = result 33 | for node in dm: 34 | if is_instance_valid(node): 35 | node.queue_free() 36 | _deletion_map[_current_callable] = ndm 37 | _new_deletion_map.erase(_current_callable) 38 | _current_callable = pc 39 | #if pc != null: 40 | return result 41 | 42 | func _build_element(callable: Callable, tree: Array, context := {node = null, index = 0}): 43 | if tree.is_empty(): return 44 | if tree[0] is Node or "new" in tree[0]: 45 | var props := {} 46 | var children := [] 47 | var calls: Array[Callable] = [] 48 | var parent: Node = context.node 49 | # Gather props and children 50 | for item in tree: 51 | if item is Dictionary: 52 | props.merge(item, true) 53 | elif item is Array: 54 | children.append(item) 55 | elif item is Callable: 56 | calls.append(item) 57 | 58 | # Create or reuse node 59 | var node: Node = null 60 | #prints('index ', context.index, node) 61 | if tree[0] is Node: 62 | node = tree[0] 63 | if parent: 64 | var node_parent = node.get_parent() 65 | var i = context.index 66 | if node_parent == null: 67 | parent.add_child(node) 68 | elif node_parent != parent: 69 | node.reparent(parent) 70 | parent.move_child(node, i) 71 | else: 72 | var should_create := true 73 | if parent: 74 | var key: String = str(props.get('name', "")).validate_node_name() 75 | if parent.has_node(key): 76 | node = parent.get_node(key) 77 | var i = context.index 78 | parent.move_child(node, i) 79 | should_create = false 80 | elif parent.get_child_count() > context.index: 81 | node = parent.get_child(context.index) 82 | var existing_class = node.get_meta("node_class", null) 83 | if existing_class == tree[0]: 84 | should_create = false 85 | if should_create: 86 | node = tree[0].new() 87 | node.set_meta("node_class", tree[0]) 88 | if parent: 89 | parent.add_child(node) 90 | _deletion_map[callable].erase(node) 91 | #_new_deletion_map.get_or_add(callable, {})[node] = true 92 | if callable in _new_deletion_map: 93 | _new_deletion_map[callable][node] = true 94 | 95 | # Disconnect signals from previous render 96 | var connections: Dictionary = node.get_meta("_gdx_connections", {}) 97 | for signal_name in connections: 98 | var c = connections[signal_name] 99 | node.disconnect(signal_name, c) 100 | connections.clear() 101 | node.set_meta("_gdx_connections", connections) 102 | 103 | # Handle props 104 | for key in props: 105 | var value = props[key] 106 | #prints(key, node, value) 107 | if key is String: 108 | if key.begins_with("on_"): 109 | var signal_name: String = key.trim_prefix("on_") 110 | if node.has_signal(signal_name): 111 | if value is Callable: 112 | #var sig: Signal = node.get(signal_name) as Signal 113 | var connection_call := func( 114 | a0 = null, 115 | a1 = null, 116 | a2 = null, 117 | a3 = null, 118 | a4 = null, 119 | a5 = null, 120 | a6 = null, 121 | a7 = null, 122 | a8 = null, 123 | a9 = null 124 | ): 125 | var args := [a0, a1, a2, a3, a4, a5, a6, a7, a8, a9] 126 | var current_c = _current_callable 127 | _current_callable = callable 128 | #value.call() 129 | value.callv(args.slice(0, value.get_argument_count())) 130 | _current_callable = current_c 131 | node.connect(signal_name, connection_call) 132 | connections[signal_name] = connection_call 133 | continue 134 | if node is Control and key.begins_with("theme_") and value is Dictionary: 135 | for p in value: 136 | node.call("add_" + key + "_override", p, value[p]) 137 | continue 138 | if key in node: 139 | var curr = node.get(key) 140 | if typeof(curr) != typeof(value) or curr != value: 141 | node.set(key, value) 142 | continue 143 | continue 144 | 145 | #print('getting indexed') 146 | #node.has_node_and_resource() 147 | var indexed_val = node.get_indexed(key) 148 | if typeof(indexed_val) == typeof(value): 149 | #print('proped') 150 | if indexed_val != value: 151 | node.set_indexed(key, value) 152 | continue 153 | continue 154 | else: 155 | push_error(key, " not found on ", node) 156 | #prints('key ', key) 157 | #print('indexed ', indexed_val) 158 | #print('value ', value) 159 | 160 | for c in calls: 161 | c.call(node) 162 | 163 | var result := [] 164 | var new_context := { 165 | node = node, 166 | index = 0 167 | } 168 | for child in children: 169 | _build_element(callable, child, new_context) 170 | context.index += 1 171 | return node 172 | elif tree[0] is Array: 173 | var result := [] 174 | for branch in tree: 175 | result.append(_build_element(callable, branch, context)) 176 | return result 177 | 178 | static func map_i(arr: Array, callable: Callable): 179 | var result := [] 180 | for i in arr.size(): 181 | var args := [arr[i], i, arr, callable] 182 | result.append(callable.callv(args.slice(0, callable.get_argument_count()))) 183 | return result 184 | 185 | static func map_key(dict: Dictionary, callable: Callable): 186 | var result := [] 187 | var i := -1 188 | for key in dict: 189 | i += 1 190 | var args := [key, dict[key], i, dict, callable] 191 | result.append(callable.callv(args.slice(0, callable.get_argument_count()))) 192 | return result 193 | 194 | 195 | -------------------------------------------------------------------------------- /_internal/path_processor/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | static var global_path := ProjectSettings.globalize_path("res://") 5 | var rfs := EditorInterface.get_resource_filesystem() 6 | var reprocess_button := Button.new() 7 | 8 | func _enter_tree() -> void: 9 | process_all_resources() 10 | #resource_saved.connect(on_resource_saved) 11 | #scene_saved.connect(on_scene_saved) 12 | #rfs.resources_reimported.connect(rfs_connection) 13 | 14 | #add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, reprocess_button) 15 | #reprocess_button.text = "Process Plugins" 16 | #reprocess_button.icon = reprocess_button.get_theme_icon("Save", "EditorIcons") 17 | #reprocess_button 18 | #reprocess_button.get_parent().move_child(reprocess_button, 0) 19 | 20 | #func _exit_tree(): 21 | #remove_control_from_docks(reprocess_button) 22 | #resource_saved.disconnect(on_resource_saved) 23 | #scene_saved.disconnect(on_scene_saved) 24 | #rfs.resources_reimported.disconnect(rfs_connection) 25 | 26 | 27 | func _save_external_data(): 28 | process_all_resources() 29 | 30 | 31 | func rfs_connection(resources): 32 | for res in resources: 33 | process_file_path(res) 34 | 35 | 36 | func on_scene_saved(file_path: String): 37 | process_file_path(file_path) 38 | 39 | 40 | func on_resource_saved(resource: Resource): 41 | print('saving resource ', resource.resource_path) 42 | process_file_path(resource.resource_path) 43 | 44 | 45 | func process_all_resources() -> void: 46 | print("processing plugins") 47 | delete_processed_folder() 48 | var paths := ["res://editor-only", "res://project-manager"] 49 | while paths.size() > 0: 50 | var path: String = paths.pop_back() 51 | for dir_name in DirAccess.get_directories_at(path): 52 | paths.push_back(path + "/" + dir_name) 53 | for file_name in DirAccess.get_files_at(path): 54 | var file_path := path + "/" + file_name 55 | process_file_path(file_path) 56 | 57 | 58 | func delete_processed_folder(path := global_path.path_join(".processed")): 59 | if DirAccess.dir_exists_absolute(path): 60 | for file_name in DirAccess.get_files_at(path): 61 | DirAccess.remove_absolute(path.path_join(file_name)) 62 | for dir_name in DirAccess.get_directories_at(path): 63 | delete_processed_folder(path.path_join(dir_name)) 64 | DirAccess.remove_absolute(path) 65 | 66 | 67 | func process_file_path(file_path: String): 68 | if file_path.begins_with("res://editor-only/") or file_path.begins_with("res://project-manager/"): 69 | # Process .import files 70 | if file_path.ends_with(".import"): 71 | process_import(file_path) 72 | return 73 | 74 | # Skip raw assets 75 | if FileAccess.file_exists(file_path + ".import"): 76 | return 77 | 78 | # Scenes and normal resources 79 | if file_path.get_extension() in ["tscn", "tres"]: 80 | process_resource(file_path) 81 | return 82 | 83 | # Scripts 84 | if file_path.ends_with(".gd"): 85 | var file := load(file_path) 86 | if file is GDScript: 87 | process_script(file) 88 | return 89 | 90 | else: 91 | process_raw_file(file_path) 92 | 93 | 94 | func should_process_path(file_path: String) -> bool: 95 | if file_path.begins_with("res://editor-only") or file_path.begins_with("res://project-manager"): 96 | if file_path.get_extension() in [ 97 | "gd", "tres", "tscn", "import" 98 | ]: 99 | return true 100 | if FileAccess.file_exists(file_path + ".import"): 101 | return true 102 | return false 103 | 104 | 105 | 106 | func globalize_path(file_path: String, processed := should_process_path(file_path)) -> String: 107 | if processed: 108 | return ProjectSettings.globalize_path(file_path).replace(global_path, global_path.path_join(".processed/")) 109 | return ProjectSettings.globalize_path(file_path) 110 | 111 | 112 | 113 | func process_raw_file(file_path: String): 114 | return 115 | #if should_process_path(file_path): 116 | #var new_path := globalize_path(file_path) 117 | #DirAccess.make_dir_recursive_absolute(new_path.get_base_dir()) 118 | #DirAccess.copy_absolute(file_path, new_path) 119 | 120 | 121 | func process_resource(file_path: String): 122 | var content := FileAccess.get_file_as_string(file_path) 123 | 124 | var i := 0 125 | while i >= 0: 126 | i = content.find("\n[ext_resource ", i + 1) 127 | if i < 0: break 128 | 129 | var path_substr := ' path="' 130 | var path_start := content.find(path_substr, i) + path_substr.length() 131 | var path_end := content.find('"', path_start) 132 | 133 | var end = content.find("]\n", i + 1) 134 | var slice := content.substr(path_start, path_end - path_start) 135 | 136 | slice = globalize_path(slice) 137 | content = content.erase(path_start, path_end - path_start) 138 | content = content.insert(path_start, slice) 139 | i = path_start + slice.length() 140 | 141 | var new_path := globalize_path(file_path, true) 142 | DirAccess.make_dir_recursive_absolute(new_path.get_base_dir()) 143 | var fs := FileAccess.open(new_path, FileAccess.WRITE) 144 | fs.store_string(content) 145 | fs.close() 146 | 147 | 148 | func process_import(file_path: String): 149 | if !file_path.ends_with(".import"): return 150 | 151 | var cfg := ConfigFile.new() 152 | cfg.load(file_path) 153 | 154 | var remap_path = cfg.get_value("remap", "path") 155 | if remap_path is String: 156 | var new_path := globalize_path(remap_path, false) 157 | cfg.set_value("remap", "path", new_path) 158 | 159 | var source_file = cfg.get_value("deps", "source_file") 160 | if source_file is String: 161 | var new_path := globalize_path(source_file, false) 162 | cfg.set_value("deps", "source_file", new_path) 163 | 164 | var dest_files = cfg.get_value("deps", "dest_files", []) 165 | if dest_files is Array: 166 | for i in dest_files.size(): 167 | dest_files[i] = globalize_path(dest_files[i], false) 168 | cfg.set_value("deps", "dest_files", dest_files) 169 | 170 | var new_path := globalize_path(file_path, true) 171 | DirAccess.make_dir_recursive_absolute(new_path.get_base_dir()) 172 | cfg.save(new_path) 173 | 174 | 175 | func process_script(file: GDScript): 176 | var new_source_code: String = file.source_code 177 | var index := 0 178 | var file_path := file.resource_path 179 | var folder_path := file_path.get_base_dir() 180 | 181 | while index > -1: 182 | index = new_source_code.find("preload(", index) 183 | if index == -1: 184 | break 185 | 186 | index += 8 187 | var string_char: String = new_source_code[index] 188 | if string_char != '"' and string_char != "'": 189 | continue 190 | 191 | var end: int = new_source_code.find(string_char, index + 1) 192 | var preload_path = new_source_code.substr(index + 1, end - index - 1) 193 | 194 | var new_path := globalize_path(preload_path) 195 | new_source_code = new_source_code.erase(index + 1, end - index - 1) 196 | new_source_code = new_source_code.insert(index + 1, new_path) 197 | 198 | index += 1 199 | 200 | var new_file := GDScript.new() 201 | new_file.source_code = new_source_code 202 | var new_path := globalize_path(file_path, true) 203 | DirAccess.make_dir_recursive_absolute(new_path.get_base_dir()) 204 | ResourceSaver.save(new_file, new_path) 205 | -------------------------------------------------------------------------------- /GDX.md: -------------------------------------------------------------------------------- 1 | 2 | # GDX 3 | This is the UI framework. This exists specifically because for most of development, you couldn't load PackedScenes due to subresource paths. So the UI had to be done in code. It's not needed anymore, but its still a nice way to use UI and is what the addon_importer and tree_inspector use. 4 | 5 | This is a lightweight framework to make UI in code a lot easier to do. It works similar to ReactJS (gdx is a reference to jsx files) 6 | 7 | ## Render 8 | Preload the `res://editor-only/included/gdx.gd` class and instantiate it. This gives you access to a `gdx.render()` function. 9 | - Call `gdx.render()` with a callable that returns your UI tree, to render your UI for the first time. 10 | - Call `gdx.render()` with no arguments to rerender that UI tree, updating it to reflect some state change. 11 | ```gdscript 12 | var gdx := preload('res://editor-only/included/gdx.gd').new() 13 | var ui = gdx.render(func(): return ( 14 | [VBoxContainer, [ 15 | [Button], 16 | [Label], 17 | [TextEdit], 18 | ] 19 | )) 20 | add_child(ui) 21 | ``` 22 | 23 | ## Structure 24 | An element structure is an array that looks like this `[NodeType or Node, { Properties }, [ Children ], Callable]`. 25 | - The first item should be: 26 | - A node type like `Button`, which will be instantiated 27 | - A raw node like `my_button` or even `self` 28 | - The order of all other items do not matter, and there can be however many of each. 29 | - A `Dictionary` item will set properties on the node. 30 | - Keys being the property names 31 | - Values being property values 32 | - Special names will be detailed in later sections, for things like signal connections and theme properties 33 | - An `Array` should be a list of other elements, which will be added as children. Arrays can be nested at any depth, until a node type / raw node is found as the first item.
34 | - So `[Container, [Button]]` is the same as `[Container, [[[[[Button]]]]]]` 35 | - Keep in mind that an element itself is an array, so you'll have quite a few nested arrays for children. 36 | - This is incorrect 37 | ```gdscript 38 | # Incorrect 39 | [VBoxContainer, [ # This array is used to denote children, so it cannot also denote the Button's element 40 | Button 41 | ]] 42 | 43 | # Correct 44 | [VBoxContainer, [ 45 | [Button] # The button gets its own array 46 | ]] 47 | ``` 48 | - You can also put a callable, which will be passed the node as the paramater. This is for when the first item is a node type, and you need direct access to it. 49 | Example: 50 | ```gdscript 51 | [Button, { 52 | text = "Click me!" 53 | }, 54 | func(it: Button): 55 | it.grab_focus() 56 | print(it.position) 57 | , # It's annoying but gdscript syntax requires a trailing comma at a certain indentation 58 | ] 59 | ``` 60 | 61 | ## Nested Properties 62 | Some property values have nested properties, like Color or Vector2. You can set those too like you would with a NodePath. And they can edit previously set props. 63 | ```gdscript 64 | [Label, { 65 | text = "My Text!", 66 | custom_minimum_size = Vector2(0, 0), 67 | 'custom_minimum_size:y' = 100, 68 | 'modulate:a' = 0.5, 69 | }] 70 | ``` 71 | > [!WARNING] 72 | > This appears to have broken in godot 4.3 stable. Avoid for now 73 | 74 | ## Signals 75 | A signal connection is just a prop with "on_" followed by the signal name.
76 | Example: 77 | ```gdscript 78 | [Button, { 79 | text = "Click me!", 80 | on_pressed = func(): 81 | print("You clicked me!") 82 | pass, 83 | }] 84 | ``` 85 | > Godot's function syntax is pretty annoying here. For some reason it complains unless that last comma is there 86 | 87 | ## Theme Properties 88 | Control nodes have various theme properties that are only editable using methods like `add_theme_color_override`.
89 | To customize them more easily in gdx, you can use special `theme_` props instead. 90 | ```gdscript 91 | [Button, { 92 | theme_constant = { 93 | outline_size = 1, 94 | icon_max_width = 24, 95 | }, 96 | theme_color = { 97 | font_color = Color.RED 98 | }, 99 | theme_font = { 100 | font = Font.new() 101 | }, 102 | theme_font_size = { 103 | font_size = 20 104 | }, 105 | theme_icon = { 106 | icon = Icon.new() 107 | }, 108 | theme_stylebox = { 109 | normal = StyleBoxEmpty.new() 110 | } 111 | }] 112 | ``` 113 | 114 | ## Rerender 115 | When you want to update the ui, you call `GDX.render()` again, without any arguments. This will rerender the the current tree of elements. GDX will do its best to reuse nodes from the previous render, rather than create new nodes every time. 116 |
117 | Rerender Example: 118 | ```gdscript 119 | var gdx := preload('res://editor-only/included/gdx.gd').new() 120 | gdx.render(func(): return ( 121 | [Button, { 122 | on_pressed = func(): 123 | gdx.render(), # rerenders the UI 124 | }] 125 | )) 126 | 127 | func some_time_later(): 128 | gdx.render() # You can rerender from outside as well 129 | ``` 130 | 131 | ## State 132 | State is the data that your UI reads. In gdx, state is external to the render function, meaning you store it in variables outside of `GDX.render()`. Due to gdscript limitations, you should store in reference types like `Array`, `Dictionary`, or `Object`. You can store in variables of a class, just not in local variables of a function. 133 | 134 | To update your UI along with your state change, you just set your state variable then call `gdx.render()` to rerender the ui.
135 | Here's a simple counter: 136 | ```gdscript 137 | var gdx := preload('res://editor-only/included/gdx.gd').new() 138 | var st := { counter = 0 } 139 | var my_ui = gdx.render(func(): return ( 140 | [Button, { 141 | text = "Count: " + str(st.counter) 142 | on_pressed = func(): 143 | st.counter += 1 144 | gdx.render(), 145 | }] 146 | )) 147 | ``` 148 | 149 | ## List Rendering / Dynamic Rendering 150 | Just map an array into an array of elements. You can also use an extra method `gdx.map_i(array, func(element, index, array, callable): return)`, if you want access to the index, array, or function itself while mapping (useful for recursion / tree rendering). 151 | ```gdscript 152 | var my_list := ["Hello", "There", "World"] 153 | var ui = gdx.render(func(): return ( 154 | [VBoxContainer, [ 155 | my_list.map(func(item): return ( 156 | [Label, { text = item }] 157 | )), 158 | [LineEdit, { 159 | on_text_submitted = func(text): 160 | my_list.append(text) 161 | gdx.render() 162 | pass, 163 | }] 164 | ]] 165 | )) 166 | ``` 167 | Dynamic rendering can cause some nodes to be needlessly recreated. This is because nodes are tracked by their index in their parent. Rendering a dynamic list makes that index unreliable, so instead you can provide a name. Elements with a name provided can always be tracked and reused. 168 | 169 | If you ran the example above, you may have noticed that the LineEdit keeps unfocusing after submitting. This is because the node was being recreated. If you give it a name, it will be reused instead 170 | ```gdscript 171 | var my_list := ["Hello", "There", "World"] 172 | var ui = GDX.render(func(): return ( 173 | [VBoxContainer, [ 174 | my_list.map(func(item): return ( 175 | [Label, { text = item }] 176 | )), 177 | [LineEdit, { 178 | name = "Text Input", 179 | on_text_submitted = func(text): 180 | my_list.append(text) 181 | GDX.render() 182 | pass, 183 | }] 184 | ]] 185 | )) 186 | ``` 187 | 188 | ## Callables in element 189 | Sometimes you just need access to the node, and do some direct calls on it. For these cases, you can also put a callable in an element's array 190 | ```gdscript 191 | [OptionButton, func(it: OptionButton): 192 | it.add_item("First") 193 | it.add_item("Second") 194 | it.add_item("Third") 195 | ] 196 | ``` 197 | 198 | ## Access element outside of render 199 | Sometimes you need access to an element outside of the render function. There are 2 ways of doing this.
200 | The best way is to create the node beforehand, and just include it as an element (Recommended) 201 | ```gdscript 202 | var my_button := Button.new() 203 | var ui = GDX.render(func(): return ( 204 | [MarginContainer, [ 205 | [my_button] 206 | ]] 207 | )) 208 | my_button.text = "Some text" 209 | ``` 210 | 211 | Another way is using a "state" and a callable, more akin to reactjs (Not recommended) 212 | ```gdscript 213 | var st := { 214 | my_button = null 215 | } 216 | var ui = GDX.render(func(): return ( 217 | [MarginContainer, [ 218 | [Button, func(it: Button): 219 | st.my_button = it 220 | ] 221 | ]] 222 | )) 223 | my_button.text = "Some text" 224 | ``` 225 | 226 | 227 | ## Streamlining `add_child` 228 | The above method can even be used to skip the `add_child(my_ui)`.
229 | All you have to do is include the parent node in the element tree.
230 | You can do all the same things to a raw node too, like setting props, callables, and children. 231 | ```gdscript 232 | GDX.render(func(): return ( 233 | [self, { "self_modulate:a" = 0.8 }, [ 234 | [VBoxContainer, [ 235 | [HBoxContainer, [ 236 | [Button] 237 | ]] 238 | ]] 239 | ]] 240 | )) 241 | ``` 242 | 243 | ## Reusable Components 244 | There's no special syntax for reusable components. You can use gdscript's existing features to achieve this.
245 | Using a class, you'll see that you need a separate instance of gdx for each component. 246 | ```gdscript 247 | class TaskItem extends HBoxContainer: 248 | var GDX := preload('res://editor-only/included/gdx.gd').new() 249 | signal deleted 250 | var text := "" 251 | var enabled := false 252 | func _ready(): 253 | GDX.render(func(): return ( 254 | [self, [ 255 | [CheckBox, { 256 | text = text 257 | }], 258 | [Button, { 259 | text = "Delete", 260 | on_pressed = deleted.emit 261 | }] 262 | ]] 263 | )) 264 | 265 | class TaskList extends VBoxContainer: 266 | var GDX := preload('res://editor-only/included/gdx.gd').new() 267 | var items := [] 268 | func _ready(): 269 | GDX.render(func(): return ( 270 | [self, [ 271 | GDX.map_i(items, func(item, i): return ( 272 | [TaskItem, { 273 | text = item.text, 274 | enabled = item.enabled, 275 | on_deleted = func(): 276 | items.remove_at(i) 277 | GDX.render(), 278 | }] 279 | )) 280 | ]] 281 | )) 282 | ``` 283 | -------------------------------------------------------------------------------- /editor-only/included/addon_import_plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | var gdx := preload('res://editor-only/included/gdx.gd').new() 5 | var Paths := preload("res://editor-only/included/paths.gd") 6 | 7 | func delete_directory(path: String): 8 | print('deleting ', path) 9 | for dir_name in DirAccess.get_directories_at(path): 10 | delete_directory(path.path_join(dir_name)) 11 | 12 | for file_name in DirAccess.get_files_at(path): 13 | DirAccess.remove_absolute(path.path_join(file_name)) 14 | 15 | DirAccess.remove_absolute(path) 16 | 17 | func import_by_colors(): 18 | var project_settings := ConfigFile.new() 19 | project_settings.load(Paths.global.path_join("project.godot")) 20 | var folder_colors: Dictionary = project_settings.get_value("file_customization", "folder_colors", {}) 21 | 22 | var rfs := EditorInterface.get_resource_filesystem() 23 | while rfs.is_scanning(): 24 | await get_tree().process_frame 25 | 26 | var plugins_to_enable := [] 27 | var plugin_exist_map := {} 28 | 29 | # Delete reds and oranges 30 | DirAccess.make_dir_absolute(Paths.local.path_join("addons")) 31 | for dir_name in DirAccess.get_directories_at("res://addons"): 32 | var path := "res://addons".path_join(dir_name) + "/" 33 | var plugin_path := path.path_join("plugin.cfg") 34 | 35 | if FileAccess.file_exists(plugin_path): 36 | plugin_exist_map[plugin_path] = true 37 | EditorInterface.set_plugin_enabled(plugin_path, false) 38 | 39 | if FileAccess.file_exists(path.path_join(".global-red")): 40 | delete_directory(Paths.local.path_join("addons").path_join(dir_name)) 41 | 42 | elif FileAccess.file_exists(path.path_join(".global-orange")): 43 | if folder_colors.get(path, "") != "orange": 44 | delete_directory(Paths.local.path_join("addons").path_join(dir_name)) 45 | 46 | for addon_path in DirAccess.get_directories_at(Paths.global.path_join("addons")): 47 | var res_path := "res://addons".path_join(addon_path) 48 | var color = folder_colors.get(res_path + '/') 49 | 50 | # Default no color 51 | if color == null: 52 | continue 53 | 54 | # Versioned 55 | if color in ["orange", "green"]: 56 | var res_cfg_path := res_path.path_join("plugin.cfg") 57 | var glb_cfg_path := Paths.global.path_join("addons").path_join(addon_path).path_join("plugin.cfg") 58 | if FileAccess.file_exists(res_cfg_path) and FileAccess.file_exists(glb_cfg_path): 59 | var cfg := ConfigFile.new() 60 | cfg.load(res_path.path_join("plugin.cfg")) 61 | var old_version = cfg.get_value("plugin", "version", "") 62 | var nfg := ConfigFile.new() 63 | nfg.load(glb_cfg_path) 64 | var new_version = nfg.get_value("plugin", "version", "") 65 | 66 | if old_version == new_version: 67 | print("Versions match. Skipping ", addon_path) 68 | continue 69 | 70 | # Make dummy .global-color file for tracking 71 | DirAccess.make_dir_recursive_absolute(res_path) 72 | FileAccess.open(res_path.path_join(".global-" + color), FileAccess.WRITE) 73 | 74 | var plugin_path := res_path.path_join("plugin.cfg") 75 | if FileAccess.file_exists(plugin_path): 76 | plugin_exist_map[res_path.path_join("plugin.cfg")] = true 77 | 78 | var paths := ["addons".path_join(addon_path)] 79 | while !paths.is_empty(): 80 | var path: String = paths.pop_back() 81 | DirAccess.make_dir_absolute(Paths.local.path_join(path)) 82 | for dir_name in DirAccess.get_directories_at(Paths.global.path_join(path)): 83 | paths.push_back(path.path_join(dir_name)) 84 | for file_name in DirAccess.get_files_at(Paths.global.path_join(path)): 85 | DirAccess.copy_absolute( 86 | Paths.global.path_join(path) + "/" + file_name, 87 | Paths.local.path_join(path) + "/" + file_name 88 | ) 89 | 90 | var popup := PopupPanel.new() 91 | gdx.render(func(): return ( 92 | [self, [ 93 | [popup, { 94 | popup_window = false, 95 | title = "Please wait", 96 | exclusive = true, 97 | borderless = false, 98 | keep_title_visible = true, 99 | }, [ 100 | [Label, { 101 | text = "Enabling Imported Addons" 102 | }] 103 | ]] 104 | ]] 105 | )) 106 | popup.popup_centered() 107 | 108 | rfs.scan_sources() 109 | while rfs.is_scanning(): 110 | await get_tree().process_frame 111 | 112 | for dir_name in DirAccess.get_directories_at("res://addons"): 113 | var cfg_path := "res://addons".path_join(dir_name).path_join("plugin.cfg") 114 | if FileAccess.file_exists(cfg_path): 115 | EditorInterface.set_plugin_enabled(cfg_path, true) 116 | popup.hide() 117 | 118 | 119 | func copy_addons(addons: Array): 120 | var plugin_folders_to_enable := [] 121 | var files_to_reimport := [] 122 | var rfs := EditorInterface.get_resource_filesystem() 123 | 124 | #var Paths.local := ProjectSettings.globalize_path("res://") 125 | 126 | for addon in addons: 127 | 128 | if addon is not String: 129 | continue 130 | 131 | var path: String = Paths.global + "/addons/" + addon + "/" 132 | 133 | # Check if plugin.cfg file exists 134 | if !FileAccess.file_exists(path + "/plugin.cfg"): 135 | push_warning("A plugin.cfg file wasn't found at path ", path, "") 136 | 137 | # Check if the plugin already exists in the project 138 | var already_exists := FileAccess.file_exists("res://addons/" + addon + "/" + "plugin.cfg") 139 | if EditorInterface.is_plugin_enabled(addon): 140 | EditorInterface.set_plugin_enabled(addon, false) 141 | 142 | # Recursively copy the folder contents into this project 143 | var dirs := [addon] 144 | var i := 0 145 | while i < dirs.size(): 146 | var dir: String = dirs[i] 147 | var global_path: String = Paths.global + '/addons/' + dir 148 | var local_path: String = Paths.local + "/addons/" + dir 149 | DirAccess.make_dir_recursive_absolute(local_path) 150 | 151 | for file in DirAccess.get_files_at(global_path): 152 | DirAccess.copy_absolute(global_path + '/' + file, local_path + '/' + file) 153 | #files_to_reimport.push_back(local_path + "/" + file) 154 | 155 | for d in DirAccess.get_directories_at(global_path): 156 | dirs.append(dir + '/' + d) 157 | i += 1 158 | 159 | plugin_folders_to_enable.push_back(addon) 160 | #if !already_exists: 161 | #plugin_folders_to_enable.push_back(addon) 162 | 163 | # The FileSystem dock doesn't properly scan new files if scanned immediately 164 | #rfs.scan() 165 | var pop := PopupPanel.new() 166 | gdx.render(func(): return ( 167 | [self, [ 168 | [pop, { popup_window = false }, [ 169 | [Label, { 170 | text = "Enabling addons..." 171 | }] 172 | ]] 173 | ]] 174 | )) 175 | 176 | pop.popup_centered() 177 | 178 | rfs.scan_sources() 179 | print("begin scan") 180 | while rfs.is_scanning(): 181 | #print('is scanning') 182 | await get_tree().process_frame 183 | await get_tree().process_frame 184 | print("end scan") 185 | 186 | for folder in plugin_folders_to_enable: 187 | EditorInterface.set_plugin_enabled(folder, true) 188 | pop.hide() 189 | pop.queue_free() 190 | 191 | func _enter_tree() -> void: 192 | 193 | if Paths.global == Paths.local: 194 | return 195 | 196 | #print(gdx.map_i([1, 2, 3], func(a): return "num-" + str(a))) 197 | 198 | await import_by_colors() 199 | 200 | var path: String = Paths.global 201 | var popup := PopupPanel.new() 202 | 203 | var current_dir_map := {} 204 | var update_dir_map := func(): 205 | current_dir_map.clear() 206 | if DirAccess.dir_exists_absolute("res://addons/"): 207 | for dir in DirAccess.get_directories_at("res://addons/"): 208 | current_dir_map[dir] = true 209 | update_dir_map.call() 210 | 211 | var addons: Array = Array(DirAccess.get_directories_at(path + "/addons")).map(func(a): 212 | return { 213 | text = a, 214 | checked = !current_dir_map.has(a), 215 | } 216 | ) 217 | await get_tree().process_frame 218 | gdx.render(func(): return ( 219 | [popup, { 220 | keep_title_visible = true, 221 | borderless = false, 222 | transient = true, 223 | popup_window = false, 224 | title = "Choose addons to copy over" 225 | }, [ 226 | [VBoxContainer, [ 227 | [CheckBox, { 228 | text = "(All)", 229 | on_toggled = func(v): 230 | for a in addons: 231 | a.checked = v 232 | gdx.render() 233 | pass, 234 | }], 235 | [MarginContainer, { 236 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 237 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 238 | }, [ 239 | [Panel], 240 | [ScrollContainer, { 241 | custom_minimum_size = Vector2(200, 200), 242 | horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED 243 | }, [ 244 | [MarginContainer, { 245 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 246 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 247 | theme_constant = { 248 | margin_left = 8, 249 | margin_right = 8, 250 | margin_top = 8, 251 | margin_bottom = 8 252 | } 253 | }, [ 254 | [VBoxContainer, [ 255 | gdx.map_i(addons, func(a): return ( 256 | 257 | [HBoxContainer, { 258 | size_flags_horizontal = Control.SIZE_EXPAND_FILL 259 | }, [ 260 | [CheckBox, { 261 | text = a.text, 262 | button_pressed = a.checked, 263 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 264 | on_toggled = func(v): 265 | a.checked = v 266 | gdx.render() 267 | pass, 268 | }], 269 | [Label, { text = "New!" }] if !current_dir_map.has(a.text) else [] 270 | ]] 271 | )) 272 | #addons.map(func(a): return ( 273 | #)) 274 | ]] 275 | ]] 276 | ]] 277 | ]], 278 | [HBoxContainer, { 279 | alignment = HBoxContainer.ALIGNMENT_CENTER, 280 | theme_constant = { 281 | separation = 10 282 | } 283 | }, [ 284 | [Button, { 285 | text = "Cancel", 286 | on_pressed = func(): 287 | popup.hide() 288 | pass, 289 | }], 290 | [Button, { 291 | text = "(Re)import", 292 | on_pressed = func(): 293 | popup.hide() 294 | copy_addons(addons.map(func(a): 295 | if a.checked: 296 | return a.text 297 | return null 298 | )) 299 | pass, 300 | }] 301 | ]] 302 | ]] 303 | ]] 304 | )) 305 | 306 | #print(Loader) 307 | EditorInterface.get_base_control().add_child(popup) 308 | 309 | var show_on_startup_setting := "addon_importer/show_on_startup" 310 | if ProjectSettings.get_setting(show_on_startup_setting, true): 311 | update_dir_map.call() 312 | popup.popup_centered() 313 | ProjectSettings.set_setting(show_on_startup_setting, false) 314 | ProjectSettings.save() 315 | add_tool_menu_item("Addon Importer", popup.popup_centered) 316 | 317 | func _exit_tree() -> void: 318 | #print('exiting') 319 | remove_tool_menu_item("Addon Importer") 320 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot Global Project 2 | A framework for making true global plugins for the Godot Game Engine. 3 | 4 | ## Installation 5 | This is not an addon, but a project for you to keep on your computer. All you need to do is open this project in godot, and a script will be injected into EditorSettings. The project has 3 directories, one for each type of plugin: 6 | 7 | - [`editor-only`](#editor-only) plugins are global, running in the Editor every time you open a project. 8 | - [`addons`](#addons) will be imported & enabled when projects load, depending on what color you assign their folders. 9 | - [`project-manager`](#project-manager) plugins will run alongside the project manager. 10 | 11 | There's a 4th `_internal` folder, where all the code that makes the project work is stored. You can ignore this unless you're curious of how things work under the hood. 12 | 13 | ## `editor-only` 14 | In this directory you store subfolders that contain your plugin. Scripts that end with `plugin.gd` and extend `EditorPlugin` will be loaded, instantiated, and ran everytime the editor loads. 15 | > As always, EditorPlugins should have `@tool` at the top of the file. 16 | ``` 17 | editor-only 18 | └── my-subfolder 19 | ├── my-plugin.gd # ✓ Automatically instantiated 20 | └── my-script.gd # x Must load manually from "my-plugin.gd" 21 | ``` 22 | 23 | They work mostly the same as normal EditorPlugins, but with a few differences. For starters, they will not be available to projects, only the editor, hence `editor-only`. 24 | 25 | These plugins are not copied into any project, rather they are loaded directly from the global-project folder. They're not like the plugins you enable in ProjectSettings, so they don't have access to: 26 | - `EditorPlugin` virtual methods 27 | - `EditorPlugin` signals 28 | 29 | Every other function should still be available though. 30 | 31 | ### Dependencies 32 | 33 | They're also handled in a special way when it comes to dependencies. Normally when loading resources, `res://` paths can only point to the current project's directory. To load global-project files, they must use absolute paths. The editor makes that very hard to do, so I've come up with a way to automatically convert them. 34 | 35 | When the global-project loads or saves, any resource in the `editor-only` or `project-manager` directory will be copied & processed into the `.processed` folder (hidden from the editor). 36 | 37 | The processed files have their paths converted to absolute, pointing to other files in the `.processed` directory. It works slightly different depending on whether its a script, a normal resource, or an imported resource: 38 | - Normal resources ending in `.tres` or `.tscn`, are copied into the `.processed` folder, and their `ext_resource` paths are converted. 39 | - For imported resources, the raw resource will not be touched. However, the `.import` file will be copied into the `.processed` folder, and its paths will be converted. 40 | - For scripts ending in `.gd`, their `preload` paths are converted. 41 | ```gdscript 42 | # This 43 | var my_res_file := preload("res://editor-only/my-plugin/my-file.gd") 44 | # Becomes something like this 45 | var my_abs_file := preload("D:/Godot/global-project//editor-only/my-plugin/my-file.gd") 46 | ``` 47 | - If you want to load manually, you can get direct access to the global-project path by preloading the included `paths.gd` script like so 48 | ```gdscript 49 | var Paths := preload("res://editor-only/included/paths.gd") 50 | 51 | # Paths.global is the global-project path 52 | var my_file_path := Paths.global + "/my_file.txt" 53 | var some_file = FileAccess.open(my_file_path, FileAccess.READ) 54 | 55 | # Paths.processed is where the processed files are stored 56 | var my_resource_path := Paths.processed + "/my_resource.tres" 57 | var some_res = load(my_resource_path) 58 | ``` 59 | 60 | > [!TIP] 61 | > The entire addon import functionality (next section) is implemented as an `editor-only` plugin in `/editor-only/included/addon-importer-plugin.gd`. You can view that code as an example. 62 | > 63 | > You'll notice that it uses a code based UI framework, [GDX](GDX.md). I made it because for most of the development, PackedScenes, or any resource with external dependencies, could not be loaded. 64 | > That's not the case anymore, but it's still included for convenience, and its how the included plugins render UI. 65 | 66 | > [!WARNING] 67 | > Binary resources, files ending in `.res` and `.scn`, won't be processed. Since they're not stored as text they can't be easily converted. 68 | > 69 | > Imported resources, like `icon.svg`, have an accompanying `.import` file. Only the `.import` will be converted and saved into the `.processed` folder. So when manually loading an asset, if you want to load it as a resource, use `Paths.processed`. But if you want the raw asset, use `Paths.global`. 70 | 71 | > [!CAUTION] 72 | > `class_name` should not be declared for `editor-only` and `project-manager` scripts. Since these files won't be in a project's directory, the editor won't load the class_names into the global namespace. Use preloads instead, which have similar intellisense. The only difference is they cannot be used as types directly. 73 | > 74 | > Built-in scripts with dependencies are also not supported, since currently it relies on the file extension to decide how to process the file. Built-in scripts are embedded in `.tres` or `.tscn` files, so they won't get processed like `.gd` scripts. Simple isolated scripts should still work though. 75 | 76 | ## `addons` 77 | Store your normal / typical addons in this directory, even ones from the AssetLib. There are various options for how these addons should be (automatically) imported, depending on folder colors. 78 | Set the folder color by `right click > Set Folder Color...` 79 | 80 | ![image](https://github.com/user-attachments/assets/0c10e6f9-49f2-4e5b-bd52-8ea9f3d61aa6) 81 | 82 | 83 | 84 | | Folder_Color | Behavior | 85 | | --------------- | --- | 86 | | 🔵 Default | By default, addons won't be automatically imported.

Instead, a popup will appear once per project, prompting you to select which addons to import. The list will show all the addons in `addons` directory of the global project.

![image](https://github.com/user-attachments/assets/ffeaeef7-5798-4d1b-8c42-f236a2c70003)
If you need to see the popup again, for example to import new addons or update existing ones, go to
**`Project > Tools > Adddon Importer`**.

All the below colors will import addons automatically, without this popup.

| 87 | | 🔴 Red |
This is ideal if you're developing & testing your own addons locally.
Red addons are synced exactly as they appear in the global project.

On load, they are deleted, and then copied over again.
This means if addons are no longer Red in the global project, they will no longer exist in your other projects.

| 88 | | 🟠 Orange |
This is recommended for asset store addons.

Only when the version in plugin.cfg has changed, the addons are deleted, then copied over.
Addons that are no longer Orange will also be deleted.

This method makes it so files aren't copied over every time.

| 89 | | 🟡 Yellow |
This is for compatability with certain addons.
This is also the same behavior as my old 'globalize-plugins' addon.

On load, all yellow plugins are copied over, but nothing is deleted.
Folders that are no longer yellow will still remain.
Even if the addon's file structure / naming changes, the outdated files and folders will remain.

Some addons store user data / preferences within its directory.
Red and Orange addons would keep overwriting those preferences, but Yellows won't.

| 90 | | 🟢 Green |
This is the same as Yellow addons, but with version comparison.
Nothing will happen if the plugin's version has not changed.

| 91 | 92 | ## `project-manager` 93 | - This folder works the same as the `editor-only` folder, except the scripts run in the project manager instead of the editor. 94 | - The scripts should not extend `EditorPlugin` and should not use any function from `EditorInterface`, since those do not exist in the project manager. But they should still end with `plugin.gd` 95 | - There is no simple api for accessing parts of the UI. You'll have to access nodes manually, but you shouldn't rely on `NodePaths`, as node names have random generated numbers in them. Use index based paths instead, like `Engine.get_main_loop().get_child(0).get_child(0)`, or recursively search the tree and use `String.match()` 96 | - I have created and included a `project-manager` plugin that adds an `inspect` button to the top right, to help you find the node in the SceneTree structure. 97 | 98 | ![image](https://github.com/user-attachments/assets/be0a8d6a-8706-4f1f-b171-3e0de38ab4cb) 99 | 100 | - This pops up a window that lets you view the project manager's scene tree. On the right is the selected node's property list. 101 | 102 | ![image](https://github.com/user-attachments/assets/47a26e6c-5b27-47c3-8b4d-f8a0f2267911) 103 | 104 | - If you click `Pick from UI`, you can click directly in the UI to pick a node. The hovered node will be highlighted in red. 105 | 106 | ![image](https://github.com/user-attachments/assets/9e684d0b-0447-4c43-8e9b-542a76685f48) 107 | 108 | 109 | 110 | ## Troubleshooting 111 | Since editor-only and project-manager plugins load alongside the editor, if one of them is bugged, the editor may crash. To fix this: 112 | - Open the global-project's `project.godot` file directly, since global plugins are disabled for that project 113 | - Fix the bugged plugin or remove it altogether 114 | - It helps to open the console version of the editor, so you can view the debug logs 115 | 116 | If for some reason the global-project also crashes: 117 | - Find `_internal/loader.gd`. This is the script that loads plugins when it itself is loaded 118 | - Temporarily rename or move the file, so that it doesn't get loaded 119 | - If you suspect that the `loader.gd` file had a bug, please report it 120 | 121 | If that still doesn't work, there might be a bug in the injected EditorSettings script. This is very unlikely due to how simple it is, but just in case you'll want to remove that script. This requires you to locate the EditorSettings file. 122 | 123 | Check [here](https://docs.godotengine.org/en/stable/tutorials/io/data_paths.html#editor-data-paths) for its location. Open the `editor_settings-4.3` (or whichever verion) file in a text editor and erase the script. It'll look something like this, just erase this whole chunk of text: 124 | 125 | ![image](https://github.com/user-attachments/assets/31cadeda-d22d-48d5-ad65-9b410cb96b5d) 126 | 127 | If you don't know how to open it in a text editor, or are too scared to make changes, just delete the whole `editor_settings-4.x` file, and godot will recreate it when it next loads. Again, its very unlikely you'll need to do this. 128 | -------------------------------------------------------------------------------- /project-manager/inspect/inspect_plugin.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | var gdx := preload("res://editor-only/included/gdx.gd").new() 4 | var helpers := preload("res://project-manager/inspect/helpers.gd") 5 | 6 | var topmost_node: Control 7 | var hovered_nodes := {} 8 | var all_controls := {} 9 | 10 | var grouped_props := {} 11 | var inspected_node: Node: 12 | set(v): 13 | inspected_node = v 14 | grouped_props.clear() 15 | var scripts := [v.get_script()] 16 | while !scripts.is_empty(): 17 | var scr = scripts.pop_back() 18 | if scr is Script: 19 | scripts.push_back(scr.get_base_script()) 20 | var sname := scr.get_global_name() as StringName 21 | if sname.is_empty(): 22 | sname = scr.resource_path() 23 | grouped_props[sname] = scr.get_script_property_list() 24 | break 25 | var classes := [v.get_class()] 26 | while !classes.is_empty(): 27 | var c = classes.pop_back() as String 28 | classes.push_back(ClassDB.get_parent_class(c)) 29 | grouped_props[c] = ClassDB.class_get_property_list(c, true) 30 | if c == "Object": 31 | break 32 | 33 | var popup := PopupPanel.new() 34 | 35 | var inspect_button := Button.new() 36 | var inspecting := false: 37 | set(v): 38 | if v: 39 | #if !popup.visible: 40 | #popup.popup_centered(Vector2(500, 400)) 41 | mouse_filter = MouseFilter.MOUSE_FILTER_STOP 42 | get_window().grab_focus() 43 | else: 44 | mouse_filter = MouseFilter.MOUSE_FILTER_IGNORE 45 | topmost_node.queue_redraw() 46 | inspecting = v 47 | 48 | func _enter_tree() -> void: 49 | print("I was spawned") 50 | set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) 51 | print("extracting") 52 | print(helpers.extract_node([0, 1, 0, 0])) 53 | print(helpers.extract_node([0, "*EditorAbout*"])) 54 | #mouse_filter = MOUSE_FILTER_PASS 55 | 56 | var root: Node = get_parent() 57 | var manager: Node = root.get_child(0, true) 58 | #print(manager.get_children(true)) 59 | 60 | var nodes := [root] 61 | while !nodes.is_empty(): 62 | var node: Node = nodes.pop_back() 63 | nodes.append_array(node.get_children(true)) 64 | connect_node(node) 65 | get_tree().node_added.connect(connect_node) 66 | 67 | mouse_filter = MouseFilter.MOUSE_FILTER_IGNORE 68 | inspect_button.icon = get_theme_icon("FileTree", "EditorIcons") 69 | inspect_button.text = "Inspect" 70 | var button_parent: Node = root.get_child(0, true).get_child(1, true).get_child(0, true).get_child(0, true).get_child(2, true) 71 | #print(button_parent) 72 | button_parent.add_child(inspect_button) 73 | button_parent.move_child(inspect_button, 0) 74 | inspect_button.pressed.connect( 75 | func(): 76 | popup.popup_centered(Vector2(600, 400)) 77 | #inspecting = true 78 | ) 79 | 80 | var scroll_container := ScrollContainer.new() 81 | var button_group := ButtonGroup.new() 82 | button_group.allow_unpress = true 83 | gdx.render(func(): return ([ 84 | [self, [ 85 | [popup, { 86 | popup_window = false, 87 | title = "Inspect Nodes", 88 | borderless = false, 89 | keep_title_visible = true, 90 | unresizable = false, 91 | #mouse_passthrough = true 92 | }, [ 93 | [HBoxContainer, { 94 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 95 | size_flags_vertical = Control.SIZE_EXPAND_FILL 96 | #collapsed = inspected_node == null, 97 | }, [ 98 | # Left Half 99 | [VBoxContainer, { 100 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 101 | size_flags_vertical = Control.SIZE_EXPAND_FILL 102 | }, [ 103 | [Button, { 104 | text = "Pick from UI", 105 | icon = get_theme_icon("ColorPick", "EditorIcons"), 106 | size_flags_horizontal = SIZE_SHRINK_BEGIN, 107 | on_pressed = func(): 108 | inspecting = true, 109 | }], 110 | [scroll_container, { 111 | #"custom_minimum_size" = Vector2(100, 200), 112 | #follow_focus = true, 113 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 114 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 115 | }, [ 116 | [GridContainer, { 117 | columns = 2 118 | }, [ 119 | [Control], 120 | [Label, {text = "Inspector"}], 121 | gdx.map_i([root], func(item: Node, index, array, callable): 122 | if item == self or self.is_ancestor_of(item): 123 | return [] 124 | return ([ 125 | [MarginContainer, [ 126 | [Button, { 127 | disabled = true if item.get_child_count(true) == 0 else false, 128 | #alignment = HORIZONTAL_ALIGNMENT_LEFT, 129 | toggle_mode = true, 130 | on_pressed = func(val = false): 131 | item.set_meta("expanded", !item.get_meta("expanded", false)) 132 | #item.set_meta('expanded', val) 133 | #item.set_meta('expanded', !item.get_meta('expanded', false)) 134 | gdx.render() 135 | pass, 136 | #theme_stylebox = { 137 | #disabled = { 138 | # 139 | #}, 140 | #} 141 | }], 142 | [Label, { 143 | text = ( 144 | "" if item.get_child_count(true) == 0 else 145 | "v" if item.get_meta("expanded", false) else 146 | ">" 147 | ), 148 | mouse_filter = MOUSE_FILTER_IGNORE, 149 | size_flags_vertical = SIZE_SHRINK_BEGIN, 150 | }] 151 | ]], 152 | [VBoxContainer, { 153 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 154 | }, [ 155 | [Button, { 156 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 157 | alignment = HORIZONTAL_ALIGNMENT_LEFT, 158 | text = item.name, 159 | toggle_mode = true, 160 | button_group = button_group, 161 | #on_gui_input = func(event = null): 162 | #print(event) 163 | #pass, 164 | #button_pressed = inspected_node == item, 165 | on_toggled = func(val): 166 | #if v: 167 | if val: 168 | inspected_node = item 169 | #elif inspected_node == item: 170 | #inspected_node = null 171 | gdx.render() 172 | pass, 173 | on_mouse_entered = func(): 174 | if item is Control: 175 | if topmost_node: 176 | topmost_node.queue_redraw() 177 | topmost_node = item 178 | hovered_nodes[item] = true 179 | item.queue_redraw() 180 | , 181 | on_mouse_exited = func(): 182 | if item is Control: 183 | if topmost_node: 184 | topmost_node.queue_redraw() 185 | if topmost_node == item: 186 | topmost_node = null 187 | hovered_nodes.erase(item) 188 | item.queue_redraw() 189 | , 190 | icon = get_theme_icon(item.get_class(), "EditorIcons") 191 | }, 192 | func(it: Button): 193 | if item.get_meta("just_grabbed_focus", false): 194 | it.grab_focus() 195 | it.set_pressed_no_signal(true) 196 | item.remove_meta("just_grabbed_focus") 197 | await get_tree().process_frame 198 | var offset := it.global_position - scroll_container.global_position 199 | var scroll := Vector2(scroll_container.scroll_horizontal, scroll_container.scroll_vertical) 200 | offset += scroll 201 | scroll_container.scroll_vertical = offset.y - scroll_container.size.y / 2.0 202 | scroll_container.scroll_horizontal = offset.x + it.size.x 203 | prints("focused pos ", it.global_position, '-', scroll, offset) 204 | elif item.has_meta("just_lost_focus"): 205 | it.release_focus() 206 | it.set_pressed_no_signal(false) 207 | it.remove_meta("just_lost_focus") 208 | get_script() 209 | , 210 | ], 211 | [GridContainer, { 212 | columns = 2, 213 | }, [ 214 | gdx.map_i(item.get_children(true), callable) 215 | ]] if item.get_meta("expanded", false) else [], 216 | ]], 217 | ] 218 | )), 219 | ]] 220 | ]], 221 | ]], 222 | 223 | # Right Half 224 | [VBoxContainer, { 225 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 226 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 227 | #size_flags_stretch_ratio = .65, 228 | }, [ 229 | [ 230 | [Label, { 231 | text = inspected_node.name 232 | }], 233 | [Button, { 234 | text = "Copy Path as Index Array", 235 | icon = get_theme_icon("ActionCopy", "EditorIcons"), 236 | on_pressed = func(): 237 | #var list := str(inspected_node.get_path()).split("/", false) 238 | #var index_list := [] 239 | var list := [] 240 | var node := inspected_node 241 | while node != root: 242 | list.push_back(node.get_index()) 243 | node = node.get_parent() 244 | list.reverse() 245 | DisplayServer.clipboard_set(str(list)) 246 | print(list) 247 | pass, 248 | }], 249 | [Label, { 250 | text = str(inspected_node.get_path()), 251 | clip_text = true, 252 | }], 253 | #[Label, { 254 | #text = "Hello end" 255 | #}], 256 | [ScrollContainer, { 257 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 258 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 259 | }, [ 260 | [VBoxContainer, { 261 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 262 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 263 | }, [ 264 | gdx.map_key( 265 | grouped_props, 266 | func(key, value, i): return ([ 267 | [Button, { 268 | text = key 269 | }], 270 | [GridContainer, { 271 | columns = 2, 272 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 273 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 274 | }, func(it: Container): it.queue_sort(), [ 275 | gdx.map_i(value, 276 | func(prop): return ([ 277 | [Button, { 278 | text = prop.name, 279 | #clip_text = true, 280 | alignment = HORIZONTAL_ALIGNMENT_LEFT, 281 | autowrap_mode = TextServer.AUTOWRAP_ARBITRARY, 282 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 283 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 284 | }], 285 | [Label, { 286 | text = inspected_node.get(prop.name), 287 | #clip_text = true, 288 | autowrap_mode = TextServer.AUTOWRAP_ARBITRARY, 289 | size_flags_horizontal = Control.SIZE_EXPAND_FILL, 290 | size_flags_vertical = Control.SIZE_EXPAND_FILL, 291 | }] 292 | ]) 293 | ) 294 | ]] 295 | ]), 296 | ) 297 | ]], 298 | #[GridContainer, { 299 | #columns = 2, 300 | #size_flags_horizontal = Control.SIZE_EXPAND_FILL, 301 | #size_flags_vertical = Control.SIZE_EXPAND_FILL 302 | #}, [ 303 | #gdx.map_i( 304 | #inspected_node.get_property_list(), 305 | #func(prop, index, array, callable): return ( 306 | #[ 307 | #[Button, { 308 | #text = prop.name, 309 | #size_flags_horizontal = Control.SIZE_EXPAND_FILL, 310 | #size_flags_vertical = Control.SIZE_EXPAND_FILL, 311 | #alignment = HORIZONTAL_ALIGNMENT_LEFT, 312 | #clip_text = true, 313 | #}], 314 | #[Label, { 315 | #text = str(inspected_node.get_indexed(prop.name)), 316 | #size_flags_horizontal = Control.SIZE_EXPAND_FILL, 317 | #clip_text = true, 318 | #}] 319 | #] 320 | #), 321 | #) 322 | #]] 323 | ]] 324 | ], 325 | ]] if inspected_node else [], 326 | ]], 327 | ]] 328 | ]] 329 | ])) 330 | popup.popup_hide.connect( 331 | func(): 332 | inspecting = false 333 | #inspect_button.button_pressed = false 334 | #inspect_button.set_pressed_no_signal(false) 335 | ) 336 | popup.focus_entered.connect( 337 | func(): 338 | topmost_node.queue_redraw() 339 | topmost_node = null 340 | ) 341 | #popup.popup_centered(Vector2(500, 400)) 342 | 343 | func connect_node(node: Node): 344 | if node is Control and node != self and node != popup and !is_ancestor_of(node): 345 | all_controls[node] = true 346 | node.draw.connect(node_draw.bind(node)) 347 | node.tree_exiting.connect(disconnect_node.bind(node)) 348 | #node.mouse_entered.connect(node_mouse_entered.bind(node)) 349 | #node.mouse_exited.connect(node_mouse_exited.bind(node)) 350 | #node.gui_input.connect(node_gui_input.bind(node)) 351 | 352 | func disconnect_node(node: Node): 353 | all_controls.erase(node) 354 | 355 | func _gui_input(event: InputEvent) -> void: 356 | if event is InputEventMouseButton and event.pressed: 357 | var old_inspected = inspected_node 358 | inspected_node = topmost_node 359 | if inspected_node: 360 | var nodes: Array[Node] = [inspected_node.get_parent()] 361 | while !nodes.is_empty(): 362 | var node := nodes.pop_back() as Node 363 | node.set_meta("expanded", true) 364 | if node == get_tree().root: 365 | break 366 | nodes.push_back(node.get_parent()) 367 | popup.grab_focus() 368 | inspected_node.set_meta("just_grabbed_focus", true) 369 | if old_inspected is Node: 370 | old_inspected.set_meta("just_lost_focus", true) 371 | inspecting = false 372 | gdx.render() 373 | elif event is InputEventMouseMotion: 374 | var recheck_top := false 375 | for node: Control in all_controls: 376 | if !is_instance_valid(node): 377 | continue 378 | if !node.is_visible_in_tree(): 379 | hovered_nodes.erase(node) 380 | continue 381 | if !node.get_window().has_focus(): 382 | hovered_nodes.erase(node) 383 | continue 384 | 385 | pass 386 | 387 | 388 | var new_val := node.get_global_rect().has_point(event.position) 389 | var old_val = hovered_nodes.get(node, false) 390 | if new_val != old_val: 391 | recheck_top = true 392 | node.queue_redraw() 393 | if new_val: 394 | hovered_nodes[node] = true 395 | else: 396 | hovered_nodes.erase(node) 397 | 398 | if recheck_top: 399 | if topmost_node: 400 | topmost_node.queue_redraw() 401 | topmost_node = null 402 | for node: Control in hovered_nodes: 403 | if !topmost_node or node.is_greater_than(topmost_node): 404 | topmost_node = node 405 | if topmost_node: 406 | topmost_node.queue_redraw() 407 | #print(topmost_node.get_path()) 408 | 409 | 410 | func node_gui_input(event: InputEvent, node: Control): 411 | pass 412 | #if event is InputEventMouseButton: 413 | #if event.pressed: 414 | #node.accept_event() 415 | 416 | func node_mouse_entered(node: Control): 417 | print("entered ", node) 418 | hovered_nodes[node] = true 419 | #node.set_meta("hovered", true) 420 | node.queue_redraw() 421 | 422 | func node_mouse_exited(node: Control): 423 | print('exited ', node) 424 | hovered_nodes[node] = false 425 | #node.set_meta("hovered", false) 426 | node.queue_redraw() 427 | 428 | func node_draw(node: Control): 429 | #if inspecting: 430 | if hovered_nodes.get(node): 431 | if node == topmost_node: 432 | node.draw_rect(Rect2(Vector2(), node.size), Color(Color.RED, .5)) 433 | #else: 434 | #node.draw_rect(Rect2(Vector2(), node.size), Color(Color.RED, .0)) 435 | #if node.get_meta("hovered", false): 436 | --------------------------------------------------------------------------------