# Copyright (c) 2023-2024 Mansur Isaev and contributors - MIT License # See `LICENSE.md` included in the source distribution for details. extends MarginContainer signal library_changed signal library_unsaved signal library_saved signal collection_changed signal open_asset_request(path: String) signal show_in_file_system_request(path: String) signal show_in_file_manager_request(path: String) signal asset_display_mode_changed(display_mode: DisplayMode) enum CollectionTabMenu { NEW, RENAME, DELETE, } enum LibraryMenu { NEW, OPEN, SAVE, SAVE_AS, } enum DisplayMode{ THUMBNAILS, LIST, } enum SortMode { NAME, NAME_REVERSE, } enum AssetContextMenu { OPEN_ASSET, COPY_PATH, COPY_UID, DELETE_ASSET, SHOW_IN_FILE_SYSTEM, SHOW_IN_FILE_MANAGER, REFRESH, MAX, } const NULL_LIBRARY: Array[Dictionary] = [] const NULL_COLLECTION: Dictionary = {} const THUMB_GRID_SIZE: int = 192 const THUMB_LIST_SIZE: int = 48 var _main_vbox: VBoxContainer = null var _collec_hbox: HBoxContainer = null var _collec_tab_bar: TabBar = null var _collec_tab_add: Button = null var _all_tabs_list: MenuButton = null var _collec_option: MenuButton = null var _main_container: PanelContainer = null var _content_vbox: VBoxContainer = null var _top_hbox: HBoxContainer = null var _asset_filter_line: LineEdit = null var _asset_sort_mode_btn: Button = null var _mode_thumb_btn: Button = null var _mode_list_btn: Button = null var _item_list: ItemList = null var _open_dialog: ConfirmationDialog = null var _save_dialog: ConfirmationDialog = null var _save_timer: Timer = null var _thumb_grid_icon_size: int = 64 var _thumb_list_icon_size: int = 16 # INFO: May be required for debugging. var _cache_enabled: bool = true var _cache_path: String = "res://.godot/thumb_cache" # Create thumbnail scene: var _viewport: SubViewport = null var _camera_2d: Camera2D = null var _camera_3d: Camera3D = null var _light_3d: DirectionalLight3D = null var _asset_display_mode: DisplayMode = DisplayMode.THUMBNAILS var _sort_mode: SortMode = SortMode.NAME var _thumbnails: Dictionary = {} var _mutex: Mutex = null var _thread: Thread = null var _thread_queue: Array[Dictionary] = [] var _thread_sem: Semaphore = null var _thread_work: bool = true var _saved: bool = true # INFO: Use key-value pairs to store collections. var _curr_lib: Array[Dictionary] = NULL_LIBRARY # Array[Dictionary[StringName, ImageTexture]] var _curr_lib_path: String = "" var _curr_collec: Dictionary = NULL_COLLECTION func _update_position_new_collection_btn() -> void: var tab_bar_total_width := float(_collec_tab_bar.get_theme_constant(&"h_separation")) for i: int in _collec_tab_bar.get_tab_count(): tab_bar_total_width += _collec_tab_bar.get_tab_rect(i).size.x _collec_tab_bar.size = Vector2(minf(_collec_tab_bar.size.x, tab_bar_total_width), 0.0) _collec_tab_add.position.x = _collec_tab_bar.size.x _all_tabs_list.set_visible(_collec_tab_bar.get_offset_buttons_visible()) static func _def_setting(name: String, value: Variant) -> Variant: if not ProjectSettings.has_setting(name): ProjectSettings.set_setting(name, value) ProjectSettings.set_initial_value(name, value) return ProjectSettings.get_setting_with_override(name) @warning_ignore("narrowing_conversion", "unsafe_method_access") func _enter_tree() -> void: _cache_enabled = _def_setting("addons/scene_library/cache/enabled", true) _cache_path = _def_setting("addons/scene_library/cache/path", "res://.godot/thumb_cache") _thumb_grid_icon_size = _def_setting("addons/scene_library/thumbnail/grid_size", 64) _thumb_list_icon_size = _def_setting("addons/scene_library/thumbnail/list_size", 16) self.add_theme_constant_override(&"margin_left", -get_theme_stylebox(&"BottomPanel", &"EditorStyles").get_margin(SIDE_LEFT)) self.add_theme_constant_override(&"margin_right", -get_theme_stylebox(&"BottomPanel", &"EditorStyles").get_margin(SIDE_RIGHT)) self.add_theme_constant_override(&"margin_top", -get_theme_stylebox(&"BottomPanel", &"EditorStyles").get_margin(SIDE_TOP)) self.set_custom_minimum_size(Vector2(0.0, 180.0)) # INFO: Required to create a tab pseudo-container background. var tabbar_background := Panel.new() tabbar_background.add_theme_stylebox_override(&"panel", get_theme_stylebox(&"tabbar_background", &"TabContainer")) self.add_child(tabbar_background) _main_vbox = VBoxContainer.new() _main_vbox.add_theme_constant_override(&"separation", 0) self.add_child(_main_vbox) _collec_hbox = HBoxContainer.new() _collec_hbox.add_theme_constant_override(&"separation", 0) # INFO: Required to calculate the position of the "new" button. _collec_hbox.sort_children.connect(_update_position_new_collection_btn) _main_vbox.add_child(_collec_hbox) _collec_tab_bar = TabBar.new() _collec_tab_bar.set_auto_translate(false) _collec_tab_bar.set_drag_to_rearrange_enabled(true) _collec_tab_bar.set_h_size_flags(Control.SIZE_EXPAND_FILL) _collec_tab_bar.set_max_tab_width(256) # TODO: Make this parameter receive global editor settings. _collec_tab_bar.set_theme_type_variation(&"TabContainer") _collec_tab_bar.add_theme_stylebox_override(&"panel", get_theme_stylebox(&"DebuggerPanel", &"EditorStyles")) _collec_tab_bar.set_select_with_rmb(true) _collec_tab_bar.add_tab("[null]") _collec_tab_bar.set_tab_disabled(0, true) _collec_tab_bar.set_tab_close_display_policy(TabBar.CLOSE_BUTTON_SHOW_NEVER) _collec_tab_bar.tab_selected.connect(_on_collection_tab_changed) _collec_tab_bar.tab_close_pressed.connect(_on_collection_tab_close_pressed) _collec_tab_bar.tab_rmb_clicked.connect(_on_collection_tab_rmb_clicked) _collec_tab_bar.active_tab_rearranged.connect(_on_collection_tab_rearranged) _collec_hbox.add_child(_collec_tab_bar) _collec_tab_add = Button.new() _collec_tab_add.set_flat(true) _collec_tab_add.set_disabled(true) _collec_tab_add.set_tooltip_text("Add a new Collection.") _collec_tab_add.set_button_icon(get_theme_icon(&"Add", &"EditorIcons")) _collec_tab_add.add_theme_color_override(&"icon_normal_color", Color(0.6, 0.6, 0.6, 0.8)) _collec_tab_add.set_h_size_flags(Control.SIZE_SHRINK_END) _collec_tab_add.pressed.connect(show_create_collection_dialog) _collec_hbox.add_child(_collec_tab_add) _all_tabs_list = MenuButton.new() _all_tabs_list.hide() _all_tabs_list.set_tooltip_text("List all tabs.") _all_tabs_list.set_button_icon(get_theme_icon(&"GuiOptionArrow", &"EditorIcons")) _all_tabs_list.add_theme_color_override(&"icon_normal_color", Color(0.6, 0.6, 0.6, 0.8)) _collec_hbox.add_child(_all_tabs_list) var popup: PopupMenu = _all_tabs_list.get_popup() popup.index_pressed.connect(_collec_tab_bar.set_current_tab) _collec_option = MenuButton.new() _collec_option.set_flat(true) _collec_option.set_button_icon(get_theme_icon(&"GuiTabMenuHl", &"EditorIcons")) _collec_option.add_theme_color_override(&"icon_normal_color", Color(0.6, 0.6, 0.6, 0.8)) _collec_hbox.add_child(_collec_option) popup = _collec_option.get_popup() popup.add_item("New Library", LibraryMenu.NEW) popup.add_item("Open Library", LibraryMenu.OPEN) popup.add_separator() popup.add_item("Save Library", LibraryMenu.SAVE) popup.add_item("Save Library As...", LibraryMenu.SAVE_AS) popup.id_pressed.connect(_on_collection_option_id_pressed) _main_container = PanelContainer.new() _main_container.set_mouse_filter(Control.MOUSE_FILTER_IGNORE) _main_container.set_v_size_flags(Control.SIZE_EXPAND_FILL) _main_container.add_theme_stylebox_override(&"panel", get_theme_stylebox(&"DebuggerPanel", &"EditorStyles")) _main_vbox.add_child(_main_container) _content_vbox = VBoxContainer.new() _main_container.add_child(_content_vbox) _top_hbox = HBoxContainer.new() _content_vbox.add_child(_top_hbox) _asset_filter_line = LineEdit.new() _asset_filter_line.set_placeholder("Filter assets") _asset_filter_line.set_clear_button_enabled(true) _asset_filter_line.set_right_icon(get_theme_icon(&"Search", &"EditorIcons")) _asset_filter_line.set_editable(false) # The value will be changed when the collection is changed. _asset_filter_line.set_h_size_flags(Control.SIZE_EXPAND_FILL) _asset_filter_line.text_changed.connect(_on_filter_assets_text_changed) _top_hbox.add_child(_asset_filter_line) _asset_sort_mode_btn = Button.new() _asset_sort_mode_btn.set_disabled(true) _asset_sort_mode_btn.set_tooltip_text("Toggle alphabetical sorting of assets") _asset_sort_mode_btn.set_flat(true) _asset_sort_mode_btn.set_toggle_mode(true) _asset_sort_mode_btn.set_button_icon(get_theme_icon(&"Sort", &"EditorIcons")) _asset_sort_mode_btn.toggled.connect(_sort_assets_button_toggled) _top_hbox.add_child(_asset_sort_mode_btn) _top_hbox.add_child(VSeparator.new()) var button_group := ButtonGroup.new() _mode_thumb_btn = Button.new() _mode_thumb_btn.set_flat(true) _mode_thumb_btn.set_disabled(true) _mode_thumb_btn.set_tooltip_text("View items as a grid of thumbnails.") _mode_thumb_btn.set_toggle_mode(true) _mode_thumb_btn.set_button_icon(get_theme_icon(&"FileThumbnail", &"EditorIcons")) _mode_thumb_btn.set_button_group(button_group) _mode_thumb_btn.pressed.connect(set_asset_display_mode.bind(DisplayMode.THUMBNAILS)) _top_hbox.add_child(_mode_thumb_btn) _mode_list_btn = Button.new() _mode_list_btn.set_flat(true) _mode_list_btn.set_disabled(true) _mode_list_btn.set_tooltip_text("View items as a list.") _mode_list_btn.set_toggle_mode(true) _mode_list_btn.set_button_icon(get_theme_icon(&"FileList", &"EditorIcons")) _mode_list_btn.set_button_group(button_group) _mode_list_btn.pressed.connect(set_asset_display_mode.bind(DisplayMode.LIST)) _top_hbox.add_child(_mode_list_btn) _item_list = AssetItemList.new() _item_list.set_focus_mode(Control.FOCUS_CLICK) _item_list.set_max_columns(0) _item_list.set_mouse_filter(Control.MOUSE_FILTER_PASS) _item_list.set_same_column_width(true) _item_list.set_select_mode(ItemList.SELECT_MULTI) _item_list.set_texture_filter(CanvasItem.TEXTURE_FILTER_LINEAR) _item_list.set_v_size_flags(Control.SIZE_EXPAND_FILL) _item_list.gui_input.connect(_on_item_list_gui_input) _item_list.item_clicked.connect(_on_item_list_item_clicked) _item_list.item_activated.connect(_on_item_list_item_activated) _content_vbox.add_child(_item_list) _asset_display_mode = _def_setting("addons/scene_library/thumbnail/mode", DisplayMode.THUMBNAILS) _update_asset_display_mode(_asset_display_mode) _open_dialog = _create_file_dialog(true) _open_dialog.set_title("Open Asset Library") _open_dialog.connect(&"file_selected", load_library) self.add_child(_open_dialog) _save_dialog = _create_file_dialog(false) _save_dialog.set_title("Save Asset Library As...") _save_dialog.connect(&"file_selected", save_library) self.add_child(_save_dialog) _save_timer = Timer.new() _save_timer.set_one_shot(true) _save_timer.set_wait_time(10.0) # Save unsaved data every 10 seconds. _save_timer.timeout.connect(_on_save_timer_timeout) library_unsaved.connect(_save_timer.start) self.add_child(_save_timer) var world_2d := World2D.new() var world_3d := World3D.new() # TODO: Add a feature to change Environment. world_3d.set_environment(get_viewport().get_world_3d().get_environment()) _viewport = SubViewport.new() _viewport.set_world_2d(world_2d) _viewport.set_world_3d(world_3d) _viewport.set_update_mode(SubViewport.UPDATE_DISABLED) # We'll update the frame manually. _viewport.set_debug_draw(Viewport.DEBUG_DRAW_DISABLE_LOD) # This is necessary to avoid visual glitches. _viewport.set_process_mode(Node.PROCESS_MODE_DISABLED) # Needs to disable animations. _viewport.set_size(Vector2i(THUMB_GRID_SIZE, THUMB_GRID_SIZE)) _viewport.set_disable_input(true) _viewport.set_transparent_background(true) _viewport.set_physics_object_picking(false) _viewport.set_default_canvas_item_texture_filter(ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter")) _viewport.set_default_canvas_item_texture_repeat(ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_repeat")) _viewport.set_fsr_sharpness(ProjectSettings.get_setting("rendering/scaling_3d/fsr_sharpness")) _viewport.set_msaa_2d(ProjectSettings.get_setting("rendering/anti_aliasing/quality/msaa_2d")) _viewport.set_msaa_3d(ProjectSettings.get_setting("rendering/anti_aliasing/quality/msaa_3d")) _viewport.set_positional_shadow_atlas_16_bits(ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_16_bits")) _viewport.set_positional_shadow_atlas_quadrant_subdiv(0, ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_quadrant_0_subdiv")) _viewport.set_positional_shadow_atlas_quadrant_subdiv(1, ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_quadrant_1_subdiv")) _viewport.set_positional_shadow_atlas_quadrant_subdiv(2, ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_quadrant_2_subdiv")) _viewport.set_positional_shadow_atlas_quadrant_subdiv(3, ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_quadrant_3_subdiv")) _viewport.set_positional_shadow_atlas_size(ProjectSettings.get_setting("rendering/lights_and_shadows/positional_shadow/atlas_size")) _viewport.set_scaling_3d_mode(ProjectSettings.get_setting("rendering/scaling_3d/mode")) _viewport.set_scaling_3d_scale(ProjectSettings.get_setting("rendering/scaling_3d/scale")) _viewport.set_screen_space_aa(ProjectSettings.get_setting("rendering/anti_aliasing/quality/screen_space_aa")) _viewport.set_texture_mipmap_bias(ProjectSettings.get_setting("rendering/textures/default_filters/texture_mipmap_bias")) self.add_child(_viewport) _camera_2d = Camera2D.new() _camera_2d.set_enabled(false) _viewport.add_child(_camera_2d) # TODO: Add a feature to set lighting. _light_3d = DirectionalLight3D.new() _light_3d.set_shadow_mode(DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS) _light_3d.set_bake_mode(Light3D.BAKE_STATIC) _light_3d.set_shadow(true) _light_3d.basis *= Basis(Vector3.UP, deg_to_rad(45.0)) _light_3d.basis *= Basis(Vector3.LEFT, deg_to_rad(65.0)) _viewport.add_child(_light_3d) _camera_3d = Camera3D.new() _camera_3d.set_current(false) _camera_3d.set_fov(22.5) _viewport.add_child(_camera_3d) # Multithreading starts here. _mutex = Mutex.new() _thread_sem = Semaphore.new() _thread = Thread.new() _thread.start(_thread_process) library_changed.connect(update_tabs) collection_changed.connect(update_item_list) asset_display_mode_changed.connect(_update_asset_display_mode) _curr_lib_path = _def_setting("addons/scene_library/library/current_library_path", "res://addons/scene-library/scene_library.cfg") load_library(_curr_lib_path) collection_changed.connect(_collec_tab_bar.size_flags_changed.emit) func _exit_tree() -> void: _mutex.lock() _thread_work = false _mutex.unlock() _thread_sem.post() if _thread.is_started(): _thread.wait_to_finish() @warning_ignore("unsafe_method_access") func _can_drop_data(at_position: Vector2, data: Variant) -> bool: if not _item_list.get_rect().has_point(at_position): return false if not data is Dictionary or data.get("type") != "files": return false if _curr_lib.is_read_only() or _curr_collec.is_read_only(): return false var files: PackedStringArray = data["files"] var rec_ext: PackedStringArray = ResourceLoader.get_recognized_extensions_for_type("PackedScene") for file: String in files: var extension: String = file.get_extension().to_lower() if not rec_ext.has(extension): return false if has_asset_path(file) or not is_valid_scene_file(file): return false return true func _drop_data(_at_position: Vector2, data: Variant) -> void: if data is Dictionary: var files: PackedStringArray = data["files"] for path: String in files: create_asset(path) func mark_saved() -> void: library_saved.emit() _saved = true func mark_unsaved() -> void: library_unsaved.emit() _saved = false func is_saved() -> bool: return _saved func set_current_library(library: Array[Dictionary]) -> void: if is_same(_curr_lib, library): return _curr_lib = library library_changed.emit() # Switch to the first tab. _collec_tab_bar.set_current_tab(0) func get_current_library() -> Array[Dictionary]: return _curr_lib func set_current_library_path(path: String) -> void: if is_same(_curr_lib_path, path): return ProjectSettings.set_setting("addons/scene_library/library/current_library_path", path) _curr_lib_path = path func get_current_library_path() -> String: return _curr_lib_path func has_collection(collection_name: String) -> bool: for collection: Dictionary in get_current_library(): if collection.name == collection_name: return true return false func create_collection(collection_name: String) -> void: assert(not has_collection(collection_name), "Collection with this name already exists.") var assets: Array[Dictionary] = [] var new_collection: Dictionary = { &"name": collection_name, &"assets": assets, } _curr_lib.push_back(new_collection) library_changed.emit() mark_unsaved() # Switch to the last tab. _collec_tab_bar.set_current_tab(_collec_tab_bar.get_tab_count() - 1) func remove_collection(index: int) -> void: _curr_lib.remove_at(index) library_changed.emit() mark_unsaved() # Swith to the prev tab. _collec_tab_bar.set_current_tab(_collec_tab_bar.get_current_tab()) func show_remove_collection_dialog(index: int) -> void: var assets: Array[Dictionary] = _curr_lib[index].assets if assets.is_empty(): return remove_collection(index) var window := ConfirmationDialog.new() window.set_size(Vector2i.ZERO) window.set_flag(Window.FLAG_RESIZE_DISABLED, true) window.focus_exited.connect(window.queue_free) window.confirmed.connect(remove_collection.bind(index)) window.set_ok_button_text("Remove") var label := Label.new() label.set_text("Are you sure you want to delete this collection? (Cannot be undone.)") window.add_child(label) self.add_child(window) window.popup_centered(Vector2i(300, 0)) func _queue_has_id(id: int) -> bool: _mutex.lock() for item: Dictionary in _thread_queue: if item.id == id: _mutex.unlock() return true _mutex.unlock() return false func _queue_update_thumbnail(id: int) -> void: if not _thumbnails.has(id) or _queue_has_id(id): return _mutex.lock() var queue_item: Dictionary = {&"id": id, &"thumb": _thumbnails[id]} _thread_queue.push_back(queue_item) _mutex.unlock() _thread_sem.post() func _get_or_create_thumbnail(id: int, path: String) -> ImageTexture: var thumb: ImageTexture = _thumbnails.get(id, null) if is_instance_valid(thumb): return thumb var cache_path: String = _get_thumb_cache_path(path) if _cache_enabled and FileAccess.file_exists(cache_path): thumb = ImageTexture.create_from_image(Image.load_from_file(cache_path)) _thumbnails[id] = thumb else: thumb = ImageTexture.create_from_image(Image.load_from_file(ProjectSettings.globalize_path("res://addons/scene-library/icons/thumb_placeholder.svg"))) _thumbnails[id] = thumb _queue_update_thumbnail(id) return thumb func _create_asset(id: int, uid: String, path: String) -> Dictionary: var asset: Dictionary = { &"id": id, &"uid": uid, &"path": path, &"thumb": _get_or_create_thumbnail(id, path), } return asset static func is_valid_scene_file(path: String) -> bool: return ResourceLoader.exists(path, "PackedScene") and ResourceLoader.get_recognized_extensions_for_type("PackedScene").has(path.get_extension().to_lower()) static func get_or_create_valid_uid(path: String) -> int: var id: int = ResourceLoader.get_resource_uid(path) if id == ResourceUID.INVALID_ID: id = ResourceUID.create_id() ResourceUID.add_id(id, path) return id func create_asset(path: String) -> void: assert(is_valid_scene_file(path), "PackedScene file was not found or has an invalid extension.") var id: int = get_or_create_valid_uid(path) var new_asset: Dictionary = _create_asset(id, ResourceUID.id_to_text(id), path) var assets: Array[Dictionary] = _curr_collec.assets assets.push_back(new_asset) collection_changed.emit() mark_unsaved() func remove_asset(id: int) -> bool: var assets: Array[Dictionary] = _curr_collec.assets for i: int in assets.size(): if assets[i].id != id: continue assets.remove_at(i) collection_changed.emit() mark_unsaved() return true return false func set_current_collection(collection: Dictionary) -> void: if is_same(_curr_collec, collection): return _curr_collec = collection _item_list.deselect_all() collection_changed.emit() func get_current_collection() -> Dictionary: return _curr_collec func has_asset_path(path: String) -> bool: for asset: Dictionary in _curr_collec.assets: if asset.path == path: return true return false func update_tabs() -> void: var is_valid: bool = not _curr_lib.is_read_only() and not _curr_lib.is_empty() _asset_filter_line.set_editable(is_valid) _collec_tab_add.set_disabled(_curr_lib.is_read_only()) _asset_sort_mode_btn.set_disabled(not is_valid) _mode_thumb_btn.set_disabled(not is_valid) _mode_list_btn.set_disabled(not is_valid) if _curr_lib.size(): _collec_tab_bar.set_tab_count(_curr_lib.size()) _collec_tab_bar.set_tab_close_display_policy(TabBar.CLOSE_BUTTON_SHOW_ACTIVE_ONLY) var popup: PopupMenu = _all_tabs_list.get_popup() popup.set_item_count(_curr_lib.size()) for i: int in _curr_lib.size(): _collec_tab_bar.set_tab_title(i, _curr_lib[i].name) _collec_tab_bar.set_tab_disabled(i, false) _collec_tab_bar.set_tab_metadata(i, _curr_lib[i]) popup.set_item_text(i, _curr_lib[i].name) else: _collec_tab_bar.set_tab_count(1) _collec_tab_bar.set_tab_close_display_policy(TabBar.CLOSE_BUTTON_SHOW_NEVER) _collec_tab_bar.set_tab_title(0, "[null]") _collec_tab_bar.set_tab_disabled(0, true) _collec_tab_bar.set_tab_metadata(0, NULL_COLLECTION) # INFO: Required to recalculate position of the "new collection" button. _collec_tab_bar.size_flags_changed.emit() @warning_ignore("unsafe_call_argument") func update_item_list() -> void: var assets: Array[Dictionary] = _curr_collec.assets _item_list.set_item_count(assets.size()) var is_list_mode: bool = _asset_display_mode == DisplayMode.LIST var filter: String = _asset_filter_line.get_text() var index: int = 0 for asset: Dictionary in assets: var path: String = asset.path if not filter.is_subsequence_ofn(path.get_file()): continue _item_list.set_item_text(index, path.get_file().get_basename()) _item_list.set_item_icon(index, asset.thumb) # NOTE: This tooltip will be hidden because used the custom tooltip. _item_list.set_item_tooltip(index, path) _item_list.set_item_metadata(index, asset) index += 1 _item_list.set_item_count(index) func set_asset_display_mode(display_mode: DisplayMode) -> void: if is_same(_asset_display_mode, display_mode): return ProjectSettings.set_setting("addons/scene_library/thumbnail/mode", display_mode) _asset_display_mode = display_mode asset_display_mode_changed.emit(display_mode) func get_asset_display_mode() -> DisplayMode: return _asset_display_mode static func sort_asset_ascending(a: Dictionary, b: Dictionary) -> bool: @warning_ignore("unsafe_method_access") return a.path.get_file() < b.path.get_file() static func sort_asset_descending(a: Dictionary, b: Dictionary) -> bool: @warning_ignore("unsafe_method_access") return a.path.get_file() > b.path.get_file() static func sort_assets(assets: Array[Dictionary], sort_mode: SortMode) -> void: if sort_mode == SortMode.NAME: assets.sort_custom(sort_asset_ascending) else: assets.sort_custom(sort_asset_descending) func set_sort_mode(sort_mode: SortMode) -> void: if is_same(_sort_mode, sort_mode): return _sort_mode = sort_mode sort_assets(_curr_collec.assets, sort_mode) collection_changed.emit() func get_sort_mode() -> SortMode: return _sort_mode func show_create_collection_dialog() -> AcceptDialog: var window := AcceptDialog.new() window.set_size(Vector2i.ZERO) window.set_title("Create New Collection") window.add_cancel_button("Cancel") window.set_flag(Window.FLAG_RESIZE_DISABLED, true) window.focus_exited.connect(window.queue_free) self.add_child(window) var ok_button: Button = window.get_ok_button() ok_button.set_text("Create") ok_button.set_disabled(true) var vbox := VBoxContainer.new() window.add_child(vbox) var label := Label.new() label.set_text("New Collection Name:") vbox.add_child(label) var line_edit := LineEdit.new() window.register_text_enter(line_edit) line_edit.set_text("new_collection") line_edit.select_all() # INFO: Disables the ability to create a collection and set a tooltip. line_edit.text_changed.connect(func(c_name: String) -> void: if c_name.is_empty(): line_edit.set_tooltip_text("Collection name is empty.") elif has_collection(c_name): line_edit.set_tooltip_text("Collection with this name already exists.") else: line_edit.set_tooltip_text("") ok_button.set_disabled(c_name.is_empty() or has_collection(c_name)) line_edit.set_right_icon(get_theme_icon(&"StatusError", &"EditorIcons") if ok_button.is_disabled() else null) ) line_edit.text_changed.emit(line_edit.get_text()) # Required for status updates. vbox.add_child(line_edit) window.confirmed.connect(func() -> void: var new_collec_name: String = line_edit.get_text() create_collection(new_collec_name) ) window.popup_centered(Vector2i(300, 0)) line_edit.grab_focus() return window func _serialize_asset(asset: Dictionary) -> Dictionary: return {"uid": asset.uid, "path": asset.path} func _serialize_assets(assets: Array[Dictionary]) -> Array[Dictionary]: var serialized: Array[Dictionary] = [] serialized.resize(assets.size()) for i: int in assets.size(): serialized[i] = _serialize_asset(assets[i]) return serialized func _serialize_collection(collection: Dictionary) -> Dictionary: return { "name": collection.name, "assets": _serialize_assets(collection.assets), } func _serialize_library(library: Array[Dictionary]) -> Array[Dictionary]: var serialized: Array[Dictionary] = [] serialized.resize(library.size()) for i: int in library.size(): serialized[i] = _serialize_collection(library[i]) return serialized func _cfg_save_library(library: Array[Dictionary], path: String) -> void: var serialized: Array[Dictionary] = _serialize_library(library) var config := ConfigFile.new() config.set_value("", "library", serialized) var error := config.save(path) assert(error == OK, error_string(error)) func _json_save_library(library: Array[Dictionary], path: String) -> void: var serialized: Array[Dictionary] = _serialize_library(library) var file := FileAccess.open(path, FileAccess.WRITE) assert(FileAccess.get_open_error() == OK, error_string(FileAccess.get_open_error())) file.store_string(JSON.stringify(serialized, "\t")) file.close() func save_library(path: String) -> void: var extension: String = path.get_extension() assert(extension == "cfg" or extension == "json", "Invalid extension.") if extension == "cfg": _cfg_save_library(_curr_lib, path) elif extension == "json": _json_save_library(_curr_lib, path) else: return mark_saved() func _deserialize_asset(asset: Dictionary) -> Dictionary: var uid: String = asset.get("uid", "") var path: String = asset.get("path", "") var id: int = ResourceUID.text_to_id(uid) # TODO: Add error handling. if id != ResourceUID.INVALID_ID and ResourceUID.has_id(id): # If the UID is valid. path = ResourceUID.get_id_path(id) # If the UID is wrong, try to load the asset by the path. # It also checks whether the file extension is valid. elif is_valid_scene_file(path): id = ResourceLoader.get_resource_uid(path) uid = ResourceUID.id_to_text(id) if not ResourceUID.has_id(id): ResourceUID.add_id(id, path) # Invalid assset. else: return {} return _create_asset(id, uid, path) func _deserialize_assets(assets: Array) -> Array[Dictionary]: var deserialized: Array[Dictionary] = [] for asset: Dictionary in assets: asset = _deserialize_asset(asset) if asset.is_empty(): continue deserialized.push_back(asset) return deserialized func _deserialize_collection(collection: Dictionary) -> Dictionary: var deserialized: Dictionary = { &"name": collection[&"name"], &"assets": _deserialize_assets(collection["assets"]) } return deserialized func _deserialize_library(library: Array) -> Array[Dictionary]: var deserialized: Array[Dictionary] = [] deserialized.resize(library.size()) for i: int in library.size(): deserialized[i] = _deserialize_collection(library[i]) return deserialized func _load_cfg(path: String) -> Array[Dictionary]: var config := ConfigFile.new() var error := config.load(path) assert(error == OK, error_string(error)) var data: Variant = config.get_value("", "library") if data is Array: return _deserialize_library(data) return NULL_LIBRARY func _load_json(path: String) -> Array[Dictionary]: var json := JSON.new() var error := json.parse(FileAccess.get_file_as_string(path)) assert(error == OK, error_string(error)) var data: Variant = json.get_data() if data is Array: return _deserialize_library(data) return NULL_LIBRARY func load_library(path: String) -> void: var library: Array[Dictionary] = [] if FileAccess.file_exists(path): var extension: String = path.get_extension() assert(extension == "cfg" or extension == "json", "Invalid extension.") if extension == "cfg": library = _load_cfg(path) elif extension == "json": library = _load_json(path) # Check for “null” value. if library.is_read_only(): return set_current_library(library) set_current_library_path(path) @warning_ignore("unsafe_method_access") func _calculate_node_rect(node: Node) -> Rect2: var rect := Rect2() if node is Node2D and node.is_visible(): # HACK: This works only in editor. rect = node.get_global_transform() * node.call(&"_edit_get_rect") for i: int in node.get_child_count(): rect = rect.merge(_calculate_node_rect(node.get_child(i))) return rect @warning_ignore("unsafe_method_access") func _calculate_node_aabb(node: Node) -> AABB: var aabb := AABB() if node is Node3D and not node.is_visible(): return aabb # NOTE: If the node is not MeshInstance3D, the AABB is not calculated correctly. # The camera may have incorrect distances to objects in the scene. elif node is MeshInstance3D: aabb = node.get_global_transform() * node.get_aabb() for i: int in node.get_child_count(): aabb = aabb.merge(_calculate_node_aabb(node.get_child(i))) return aabb func _focus_camera_on_node_2d(node: Node) -> void: var rect: Rect2 = _calculate_node_rect(node) _camera_2d.set_position(rect.get_center()) var zoom_ratio: float = THUMB_GRID_SIZE / maxf(rect.size.x, rect.size.y) _camera_2d.set_zoom(Vector2(zoom_ratio, zoom_ratio)) func _focus_camera_on_node_3d(node: Node) -> void: var transform := Transform3D.IDENTITY # TODO: Add a feature to configure the rotation of the camera. transform.basis *= Basis(Vector3.UP, deg_to_rad(40.0)) transform.basis *= Basis(Vector3.LEFT, deg_to_rad(22.5)) var aabb: AABB = _calculate_node_aabb(node) var distance: float = aabb.get_longest_axis_size() / tan(deg_to_rad(_camera_3d.get_fov()) * 0.5) transform.origin = transform * (Vector3.BACK * distance) + aabb.get_center() _camera_3d.set_global_transform(transform.orthonormalized()) func _get_thumb_cache_dir() -> String: return ProjectSettings.globalize_path(_cache_path) func _get_thumb_cache_path(path: String) -> String: return _get_thumb_cache_dir().path_join(path.md5_text()) + ".png" func _save_thumb_to_disk(id: int, image: Image) -> void: if not DirAccess.dir_exists_absolute(_get_thumb_cache_dir()): var error := DirAccess.make_dir_absolute(_get_thumb_cache_dir()) assert(error == OK, error_string(error)) var error := image.save_png(_get_thumb_cache_path(ResourceUID.get_id_path(id))) assert(error == OK, error_string(error)) func _create_thumb(item: Dictionary, callback: Callable) -> void: var path: String = ResourceUID.get_id_path(item.id) if not is_valid_scene_file(path): return callback.call() var packed_scene := ResourceLoader.load(path, "PackedScene") as PackedScene # INFO: Could be null if, for example, the dependencies are broken. if not is_instance_valid(packed_scene) or not packed_scene.can_instantiate(): return callback.call() var instance: Node = packed_scene.instantiate() _viewport.call_deferred(&"add_child", instance) await instance.ready if instance is Node2D: _camera_3d.set_current(false) _camera_2d.set_enabled(true) _focus_camera_on_node_2d(instance) else: _camera_2d.set_enabled(false) _camera_3d.set_current(true) _focus_camera_on_node_3d(instance) await RenderingServer.frame_pre_draw _viewport.set_update_mode(SubViewport.UPDATE_ONCE) await RenderingServer.frame_post_draw var image: Image = _viewport.get_texture().get_image() image.resize(THUMB_GRID_SIZE, THUMB_GRID_SIZE, Image.INTERPOLATE_LANCZOS) var thumb: ImageTexture = item.thumb thumb.update(image) if _cache_enabled: _save_thumb_to_disk(item.id, image) instance.call_deferred(&"free") await instance.tree_exited callback.call() func _thread_process() -> void: var semaphore := Semaphore.new() while _thread_work: if _thread_queue.is_empty(): _thread_sem.wait() else: _mutex.lock() var item: Dictionary = _thread_queue.pop_front() _mutex.unlock() # This ensures that this method will be executed in the main thread. call_deferred_thread_group(&"_create_thumb", item, semaphore.post) semaphore.wait() func handle_scene_saved(path: String) -> void: # INFO: When we save a scene, we try to update the asset thumbnail. # The "_queue_update_thumbnail" method will not create new thumbnails if they have not been previously created. _queue_update_thumbnail(ResourceLoader.get_resource_uid(path)) func handle_file_moved(old_file: String, new_file: String) -> void: if not _thumbnails.has(ResourceLoader.get_resource_uid(new_file)): return for collection: Dictionary in _curr_lib: for asset: Dictionary in collection.assets: if asset.path == old_file: asset.path = new_file break collection_changed.emit() func handle_file_removed(file: String) -> void: # TODO: Need to add Dictionary for asset path. # Because we can't use UID for deleted files. # And we have to go through all collections and assets. var removed: int = 0 for collection: Dictionary in _curr_lib: var assets: Array[Dictionary] = collection.assets for i: int in assets.size(): if assets[i].path != file: continue assets.remove_at(i) removed += 1 break if removed: collection_changed.emit() func _on_collection_tab_changed(tab: int) -> void: set_current_collection(_collec_tab_bar.get_tab_metadata(tab)) func _on_collection_tab_close_pressed(tab: int) -> void: show_remove_collection_dialog(tab) func _on_collection_tab_rmb_clicked(tab: int) -> void: var collection: Dictionary = _collec_tab_bar.get_tab_metadata(tab) var popup := PopupMenu.new() popup.id_pressed.connect(func(option: CollectionTabMenu) -> void: match option: CollectionTabMenu.NEW: show_create_collection_dialog() CollectionTabMenu.RENAME: var old_name: String = collection.name var rename_collec_window := AcceptDialog.new() rename_collec_window.set_size(Vector2i.ZERO) rename_collec_window.set_title("Rename Collection") rename_collec_window.add_cancel_button("Cancel") rename_collec_window.set_flag(Window.FLAG_RESIZE_DISABLED, true) rename_collec_window.focus_exited.connect(rename_collec_window.queue_free) var ok_button: Button = rename_collec_window.get_ok_button() ok_button.set_text("OK") ok_button.set_disabled(true) var vbox := VBoxContainer.new() rename_collec_window.add_child(vbox) var label := Label.new() label.set_text("Change Collection Name:") vbox.add_child(label) var line_edit := LineEdit.new() line_edit.set_select_all_on_focus(true) line_edit.set_text(old_name) rename_collec_window.register_text_enter(line_edit) # INFO: Disables the ability to create a collection and set a tooltip. line_edit.text_changed.connect(func(new_name: String) -> void: var is_valid := false if new_name.is_empty(): line_edit.set_tooltip_text("Collection name is empty.") elif has_collection(new_name): line_edit.set_tooltip_text("Collection with this name already exists.") else: line_edit.set_tooltip_text("") is_valid = true ok_button.set_disabled(not is_valid) line_edit.set_right_icon(null if is_valid else get_theme_icon(&"StatusError", &"EditorIcons")) ) line_edit.text_changed.emit(line_edit.get_text()) # Required for update status. vbox.add_child(line_edit) rename_collec_window.confirmed.connect(func() -> void: collection.name = line_edit.get_text() _collec_tab_bar.set_tab_title(tab, line_edit.get_text()) mark_unsaved() ) self.add_child(rename_collec_window) rename_collec_window.popup_centered(Vector2i(300, 0)) line_edit.grab_focus() CollectionTabMenu.DELETE: show_remove_collection_dialog(tab) ) popup.focus_exited.connect(popup.queue_free) self.add_child(popup) if collection.is_read_only(): # If "null" collection. # BUG: You can't see it because the tab is disabled. popup.add_item("New Collection", CollectionTabMenu.NEW) else: popup.add_item("New Collection", CollectionTabMenu.NEW) popup.add_separator() popup.add_item("Rename Collection", CollectionTabMenu.RENAME) popup.add_item("Delete Collection", CollectionTabMenu.DELETE) popup.popup(Rect2i(get_screen_position() + get_local_mouse_position(), Vector2i.ZERO)) func _on_collection_tab_rearranged(_to_idx: int) -> void: for i: int in _collec_tab_bar.get_tab_count(): _curr_lib[i] = _collec_tab_bar.get_tab_metadata(i) func _create_file_dialog(open: bool) -> ConfirmationDialog: var dialog: ConfirmationDialog = null if Engine.is_editor_hint(): # Works only in the editor. var editor_file_dialog: EditorFileDialog = ClassDB.instantiate(&"EditorFileDialog") editor_file_dialog.set_access(EditorFileDialog.ACCESS_FILESYSTEM) editor_file_dialog.set_file_mode(EditorFileDialog.FILE_MODE_OPEN_FILE if open else EditorFileDialog.FILE_MODE_SAVE_FILE) editor_file_dialog.add_filter("*.cfg", "Config File") editor_file_dialog.add_filter("*.json", "JSON File") dialog = editor_file_dialog else: var file_dialog := FileDialog.new() file_dialog.set_access(FileDialog.ACCESS_FILESYSTEM) file_dialog.set_file_mode(FileDialog.FILE_MODE_OPEN_FILE if open else FileDialog.FILE_MODE_SAVE_FILE) file_dialog.add_filter("*.cfg", "Config File") file_dialog.add_filter("*.json", "JSON File") dialog = file_dialog dialog.set_exclusive(true) return dialog func _popup_file_dialog(window: Window) -> void: window.popup_centered_clamped(Vector2(1050, 700) * DisplayServer.screen_get_scale(), 0.8) func _on_collection_option_id_pressed(option: LibraryMenu) -> void: match option: # TODO: Add a feature to check if the current library is saved. LibraryMenu.NEW: var new_library: Array[Dictionary] = [] set_current_library(new_library) _curr_lib_path = "" LibraryMenu.OPEN: _popup_file_dialog(_open_dialog) LibraryMenu.SAVE when not _curr_lib_path.is_empty(): save_library(_curr_lib_path) LibraryMenu.SAVE, LibraryMenu.SAVE_AS: _popup_file_dialog(_save_dialog) func _on_filter_assets_text_changed(_filter: String) -> void: update_item_list() func _sort_assets_button_toggled(reverse: bool) -> void: set_sort_mode(SortMode.NAME_REVERSE if reverse else SortMode.NAME) func _update_thumb_icon_size(display_mode: DisplayMode) -> void: if display_mode == DisplayMode.THUMBNAILS: _item_list.set_fixed_column_width(_thumb_grid_icon_size * 1.5) _item_list.set_fixed_icon_size(Vector2i(_thumb_grid_icon_size, _thumb_grid_icon_size)) else: _item_list.set_fixed_column_width(0) _item_list.set_fixed_icon_size(Vector2i(_thumb_list_icon_size, _thumb_list_icon_size)) func _update_asset_display_mode(display_mode: DisplayMode) -> void: if display_mode == DisplayMode.THUMBNAILS: _item_list.set_max_columns(0) _item_list.set_icon_mode(ItemList.ICON_MODE_TOP) _item_list.set_max_text_lines(2) _mode_thumb_btn.set_pressed_no_signal(true) else: _item_list.set_max_columns(0) _item_list.set_icon_mode(ItemList.ICON_MODE_LEFT) _item_list.set_max_text_lines(1) _mode_list_btn.set_pressed_no_signal(true) for i: int in _item_list.get_item_count(): var asset: Dictionary = _item_list.get_item_metadata(i) _item_list.set_item_icon(i, asset.thumb) _update_thumb_icon_size(display_mode) func _set_thumb_grid_icon_size(icon_size: int) -> void: icon_size = clampi(icon_size, THUMB_LIST_SIZE, THUMB_GRID_SIZE) if _thumb_grid_icon_size == icon_size: return ProjectSettings.set_setting("addons/scene_library/thumbnail/grid_size", icon_size) _thumb_grid_icon_size = icon_size _update_thumb_icon_size(_asset_display_mode) func _set_thumb_list_icon_size(icon_size: int) -> void: const ICON_MIN_SIZE = 16 icon_size = clampi(icon_size, ICON_MIN_SIZE, THUMB_LIST_SIZE) if _thumb_list_icon_size == icon_size: return ProjectSettings.set_setting("addons/scene_library/thumbnail/list_size", icon_size) _thumb_list_icon_size = icon_size _update_thumb_icon_size(_asset_display_mode) func _on_item_list_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.is_pressed() and event.is_command_or_control_pressed(): const ICON_GRID_STEP = 8 const ICON_LIST_STEP = 4 match event.get_button_index(): MOUSE_BUTTON_WHEEL_UP: if _asset_display_mode == DisplayMode.THUMBNAILS: _set_thumb_grid_icon_size(_thumb_grid_icon_size + ICON_GRID_STEP) else: _set_thumb_list_icon_size(_thumb_list_icon_size + ICON_LIST_STEP) MOUSE_BUTTON_WHEEL_DOWN: if _asset_display_mode == DisplayMode.THUMBNAILS: _set_thumb_grid_icon_size(_thumb_grid_icon_size - ICON_GRID_STEP) else: _set_thumb_list_icon_size(_thumb_list_icon_size - ICON_LIST_STEP) _: return accept_event() func _on_item_list_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void: if mouse_button_index != MOUSE_BUTTON_RIGHT: return _item_list.select(index, false) var selected_assets: PackedInt32Array = _item_list.get_selected_items() var popup := PopupMenu.new() popup.connect(&"focus_exited", popup.queue_free) popup.connect(&"id_pressed", func(option: AssetContextMenu) -> void: var asset: Dictionary = _item_list.get_item_metadata(selected_assets[0]) match option: AssetContextMenu.OPEN_ASSET: open_asset_request.emit(asset.path) AssetContextMenu.COPY_PATH: DisplayServer.clipboard_set(asset.path) AssetContextMenu.COPY_UID: DisplayServer.clipboard_set(asset.uid) AssetContextMenu.DELETE_ASSET: var assets: Array[Dictionary] = _curr_collec.assets if selected_assets.size() == 1: assets.remove_at(selected_assets[0]) else: selected_assets.reverse() for i: int in selected_assets: assets.remove_at(i) collection_changed.emit() mark_unsaved() AssetContextMenu.SHOW_IN_FILE_SYSTEM: show_in_file_system_request.emit(asset.path) AssetContextMenu.SHOW_IN_FILE_MANAGER: show_in_file_manager_request.emit(asset.path) AssetContextMenu.REFRESH: for i: int in selected_assets: asset = _item_list.get_item_metadata(i) _queue_update_thumbnail(asset.id) ) self.add_child(popup) if selected_assets.size() == 1: # If only one asset is selected. popup.add_item("Open", AssetContextMenu.OPEN_ASSET) popup.set_item_icon(-1, get_theme_icon(&"Load", &"EditorIcons")) popup.add_separator() popup.add_item("Copy Path", AssetContextMenu.COPY_PATH) popup.set_item_icon(-1, get_theme_icon(&"ActionCopy", &"EditorIcons")) popup.add_item("Copy UID", AssetContextMenu.COPY_UID) popup.set_item_icon(-1, get_theme_icon(&"Instance", &"EditorIcons")) popup.add_item("Delete", AssetContextMenu.DELETE_ASSET) popup.set_item_icon(-1, get_theme_icon(&"Remove", &"EditorIcons")) popup.add_separator() popup.add_item("Show in FileSystem", AssetContextMenu.SHOW_IN_FILE_SYSTEM) popup.set_item_icon(-1, get_theme_icon(&"Filesystem", &"EditorIcons")) popup.add_item("Show in File Manager", AssetContextMenu.SHOW_IN_FILE_MANAGER) popup.set_item_icon(-1, get_theme_icon(&"Folder", &"EditorIcons")) popup.add_separator() popup.add_item("Refresh", AssetContextMenu.REFRESH) popup.set_item_icon(-1, get_theme_icon(&"Reload", &"EditorIcons")) else: # If many assets are selected. popup.add_item("Delete", AssetContextMenu.DELETE_ASSET) popup.set_item_icon(popup.get_item_index(AssetContextMenu.DELETE_ASSET), get_theme_icon(&"Remove", &"EditorIcons")) popup.add_item("Refresh", AssetContextMenu.REFRESH) popup.set_item_icon(-1, get_theme_icon(&"Reload", &"EditorIcons")) popup.popup(Rect2i(_item_list.get_screen_position() + at_position, Vector2i.ZERO)) func _on_item_list_item_activated(index: int) -> void: var asset: Dictionary = _item_list.get_item_metadata(index) open_asset_request.emit(asset.path) func _on_save_timer_timeout() -> void: if _curr_lib_path.is_empty(): return save_library(_curr_lib_path) class AssetItemList extends ItemList: func _gui_input(event: InputEvent) -> void: if event.is_action_pressed(&"ui_text_select_all"): for i: int in get_item_count(): select(i, false) accept_event() func _create_drag_preview(files: PackedStringArray) -> Control: const MAX_ROWS = 6 var vbox := VBoxContainer.new() var num_rows := mini(files.size(), MAX_ROWS) for i: int in num_rows: var hbox := HBoxContainer.new() vbox.add_child(hbox) var icon := TextureRect.new() icon.set_texture(get_theme_icon(&"File", &"EditorIcons")) icon.set_stretch_mode(TextureRect.STRETCH_KEEP_CENTERED) icon.set_size(Vector2(16.0, 16.0)) hbox.add_child(icon) var label := Label.new() label.set_text(files[i].get_file().get_basename()) hbox.add_child(label) if files.size() > num_rows: var label := Label.new() label.set_text("%d more files" % int(files.size() - num_rows)) vbox.add_child(label) return vbox func _get_drag_data(at_position: Vector2) -> Variant: var item: int = get_item_at_position(at_position) if item < 0: return null var files := PackedStringArray() for i: int in get_selected_items(): var asset: Dictionary = get_item_metadata(i) files.push_back(asset.path) set_drag_preview(_create_drag_preview(files)) return {"type": "files", "files": files} func _make_custom_tooltip(_for_text: String) -> Object: var item: int = get_item_at_position(get_local_mouse_position()) if item < 0: return null var asset: Dictionary = get_item_metadata(item) if asset.is_empty(): return null var vbox := VBoxContainer.new() var thumb_rect := TextureRect.new() thumb_rect.set_expand_mode(TextureRect.EXPAND_IGNORE_SIZE) thumb_rect.set_h_size_flags(Control.SIZE_SHRINK_CENTER) thumb_rect.set_v_size_flags(Control.SIZE_SHRINK_CENTER) thumb_rect.set_custom_minimum_size(Vector2(THUMB_GRID_SIZE, THUMB_GRID_SIZE)) thumb_rect.set_texture(asset.thumb) vbox.add_child(thumb_rect) var label := Label.new() label.set_text(asset.path) vbox.add_child(label) return vbox