@tool extends PanelContainer const HEIGHT_RATIO := 0.15 # drawer height (share of the editor window) const MIN_HEIGHT := 60.0 # the content imposes a minimum anyway (top / bottom bars) const CFG_VERSION := 4 # v3: more compact drawer by default (reduced height) const SIDE_GAP := 40.0 # gap left on each side const BOTTOM_GAP := 40.0 # gap below the drawer (keeps the Output / Debugger / Audio bar visible...) const MAX_WIDTH := 2200.0 const DETAILS_MIN_WIDTH := 140.0 # minimum width of the details panel (resizable beyond it) const ANIM_TIME := 0.2 const SHOW_HINT := true # "Asset Drawer Ctrl+Space" pill at the bottom of the editor const HINT_BOTTOM := 44.0 const MAX_ITEMS := 3000 # cap on displayed items (performance) const SEARCH_DELAY := 0.18 # search debounce const SAVE_DELAY := 0.6 # config save debounce const PREVIEW_DELAY := 0.08 # thumbnail loading debounce const STICKY_MAX := 3 # max number of parent folders pinned at the top of the tree # _sticky_row_h() = height reserved per pinned row (always equal to one full row). const ICON_BUCKETS := [24, 64, 96, 160] const HIDDEN_ALPHA := 0.45 # opacity of hidden items when they are shown const CFG_PATH := "res://.godot/asset_drawer.cfg" const L := preload("res://addons/asset_drawer/lang.gd") # FR / EN translations # --- Palette -------------------------------------------------------------- # The background / border / text colors below are VARIABLES: the values written here # are only a fallback. They are recomputed at startup by _update_palette() from the # editor theme (Editor Settings > Interface > Theme > Base Color + Contrast). # Principle: few hues, a clear elevation hierarchy (from the darkest "foundation" # to the lightest "under the fingers"), an accent used sparingly (drawer border, # selection, focus) rather than spread everywhere, and a single gray scale for text. var COLOR_BG_MAIN := Color(0.045, 0.05, 0.067, 1.0) # foundation (the drawer itself) var COLOR_HEADER := Color(0.083, 0.097, 0.14, 1.0) # top bar (nav + search + filters) var COLOR_LEFT_PANEL := Color(0.083, 0.097, 0.14, 1.0) # folder tree var COLOR_CENTER_PANEL := Color(0.083, 0.097, 0.14, 1.0) # asset grid/list, slightly raised var COLOR_RIGHT_PANEL := Color(0.083, 0.097, 0.14, 1.0) # details panel var COLOR_FOOTER := Color(0.084, 0.091, 0.114) # status bar, the darkest (bottom anchor) var COLOR_BORDER := Color(0.26, 0.30, 0.37, 0.65) # thin edge between panels, not a hard line var COLOR_PATH_BG := Color(0.062, 0.068, 0.086) # shared background of the top bar containers const BAR_H := 26.0 # shared height of the top bar containers # Text hierarchy: a single gray scale (never pure white, easier on the eyes). var TEXT_PRIMARY := Color(0.93, 0.94, 0.97) # titles, name of the selected file var TEXT_SECONDARY := Color(0.93, 0.94, 0.97, 0.62) # paths, meta info, status text var TEXT_MUTED := Color(0.93, 0.94, 0.97, 0.38) # subtle text (placeholders, separators) var COLOR_POPUP_BG := Color(0.09, 0.10, 0.13, 0.97) # notification background var COLOR_CARD_BG := Color(0.10, 0.12, 0.15, 0.96) # background of small cards (drag preview, drop target) var COLOR_TRACK := Color(0.42, 0.46, 0.54) # track of the zoom slider var _font_color := Color(0.93, 0.94, 0.97) # editor text color (base of the _ov overlays) var _palette_sig := "" # signature of the theme used to build the UI # Semantic colors: each one has a single meaning across the whole UI. const COLOR_FAV := Color(0.98, 0.78, 0.32) # favorites (gold) const COLOR_RES_ACTION := Color(0.34, 0.76, 0.48) # copy/paste a resource (green) const COLOR_DANGER := Color(0.90, 0.36, 0.40) # deletion, errors (red) # Color names = the ones stored by Godot's FileSystem dock const FOLDER_COLORS := { "red": Color(0.95, 0.35, 0.35), "orange": Color(0.98, 0.58, 0.25), "yellow": Color(0.96, 0.80, 0.25), "green": Color(0.35, 0.78, 0.45), "teal": Color(0.25, 0.78, 0.75), "cyan": Color(0.35, 0.75, 0.95), "blue": Color(0.35, 0.65, 0.98), "purple": Color(0.68, 0.45, 0.95), "pink": Color(0.95, 0.45, 0.72), "gray": Color(0.60, 0.62, 0.68) } const DEFAULT_FOLDER_COLOR := Color(0.35, 0.65, 0.98) # Color strip under each thumbnail (in list view: bar on the left), one color per asset type, # like in Unreal's Content Browser. Set SHOW_TYPE_BAR to false to disable it. const SHOW_TYPE_BAR := true const TYPE_COLORS := { "scene": Color(0.30, 0.58, 0.98), # .tscn / .scn scenes — blue "script": Color(0.35, 0.78, 0.45), # scripts — green "model": Color(0.68, 0.45, 0.95), # 3D models and meshes — purple "image": Color(0.98, 0.50, 0.16), # textures / images — bright orange "audio": Color(0.95, 0.80, 0.18), # sounds — yellow/gold, clearly distinct from the orange above "shader": Color(0.95, 0.45, 0.72), # shaders — pink "material": Color(0.20, 0.80, 0.55), # materials — teal "animation": Color(0.35, 0.68, 0.98), # animations — sky blue, distinct from the teal above "font": Color(0.85, 0.72, 0.55), # fonts — beige "resource": Color(0.60, 0.62, 0.68), # any other resource / file — gray } var is_open := false var pinned := false var current_dir := "res://" var _history: PackedStringArray = PackedStringArray(["res://"]) var _history_i := 0 var _favorites: PackedStringArray = PackedStringArray() var _fav_migrated := false # old local favorites already merged into Godot's var _recents: PackedStringArray = PackedStringArray() var _sets: Dictionary = {} # set name -> Array of paths var _active_set := "" var _collapsed: Dictionary = {} var _tween: Tween var _glow_tween: Tween var _panel_style: StyleBoxFlat var _toast: PanelContainer var _toast_overlay: Control var _toast_lbl: Label var _toast_tween: Tween var _height_ratio := HEIGHT_RATIO var _resizing := false var _view_list := false var _details_visible := true var _context_path := "" var _clip_resource: Resource var _clip_resource_name := "" var _paste_pop: Button var _paste_target: EditorResourcePicker var _paste_tween: Tween var _copy_pop: Button var _copy_target_path := "" var _copy_tween: Tween var _pending_select := "" var _selected_filter := 0 var _hidden: PackedStringArray = PackedStringArray() # hidden items (folder = path ending with "/") var _show_hidden := false var _search_all := true # search the whole project (otherwise: current folder) var _last_script_ext := "gd" var _index: Array = [] # flat index of file/folder names (global search) var _index_dirty := true var _btn_scope: Button var _active_left_tab := 0 var _syncing_tree := false var _zoom_grid := 80.0 # remembered grid-mode zoom (thumbnail size) var _zoom_list := 22.0 # remembered list-mode zoom (icon size) var _split: HSplitContainer var _left_vbox: VBoxContainer var _tree: Tree var _tree_tools: HBoxContainer var _sticky_box: Control var _tree_vscroll: VScrollBar var _sticky_sig := "" var _row_pitch := 0.0 var _pending_reveal: TreeItem var _center_tween: Tween var _tab_list: ItemList var _btn_new_set: Button var _breadcrumbs: HBoxContainer var _search: LineEdit var _zoom: HSlider var _list: ItemList var _empty_state: VBoxContainer var _empty_lbl: Label var _empty_link: LinkButton var _status: Label var _details_panel: PanelContainer var _details_width := 190.0 var _preview: TextureRect var _preview_shadows: Array[TextureRect] = [] var _details_name: Label var _details_path: Label var _lbl_type: Label var _lbl_size: Label var _lbl_modified: Label var _lbl_uid: Label var _btn_open: Button var _btn_fav: Button var _btn_copy_res: Button var _btn_copy_path: Button var _details_scroll: ScrollContainer var _details_dv: VBoxContainer var _details_btn_box: BoxContainer var _details_type_row: HFlowContainer var _chip_type: PanelContainer var _chip_size: PanelContainer var _chip_date: PanelContainer var _field_path: VBoxContainer var _field_uid: VBoxContainer var _details_card: PanelContainer var _details_sep_info: ColorRect var _details_card_sep: ColorRect var _btn_labels: Dictionary = {} # button -> [label, tooltip] var _details_compact := false # true: buttons on a single row of icon-only buttons var _btn_grid: Button var _btn_list: Button var _btn_details: Button var _btn_pin: Button var _filter_buttons: Array[Button] = [] var _left_tab_buttons: Array[Button] = [] var _ctx_popup: PanelContainer var _copy_hiding := false var _paste_hiding := false var _ctx_vbox: VBoxContainer var _grip: ColorRect var _hd_icon_cache := {} var _hint: PanelContainer var _hint_tween: Tween var _btn_back: Button var _empty_reset: Button var _btn_fwd: Button var _btn_up: Button var _outside_close_msec := -1000 var _card_images := {} var _preview_cache := {} # path -> Godot thumbnail (null = failure) var _preview_pending := {} # paths already sent to the previewer var _svg_queue: Array = [] # [path, target size]: SVGs we rasterize ourselves (sharp at any size) var _svg_px := {} # path -> size (px) of the last successful rasterization var _svg_fail := {} # SVGs Godot could not rasterize: we fall back to its thumbnail var _path_index := {} # path -> index in _list var _item_colors := PackedColorArray() # color of each _list item's strip (transparent = none) var _bar_style: StyleBoxFlat var _dir_paths := {} # paths of the displayed folders var _drop_list_idx := -1 # list folder highlighted during a drag and drop var _drop_tree_item: TreeItem # tree folder highlighted during a drag and drop var _drop_whole := false # drop into the current folder (empty area): frame around the list var _mq_active := false # rectangle selection ("lasso") in progress var _mq_moved := false var _mq_start := Vector2.ZERO # start in "content" coordinates (scroll included) var _mq_cur := Vector2.ZERO # current mouse position (list coordinates) var _mq_base: PackedInt32Array = PackedInt32Array() # starting selection (Ctrl / Shift) var _tree_items := {} # normalized path -> TreeItem var _dirty := true var _tree_dirty := true var _save_dirty := false var _truncated := false var _icon_bucket := 0 var _search_timer: Timer var _save_timer: Timer var _preview_timer: Timer func _ready() -> void: visible = false modulate.a = 0.0 mouse_filter = Control.MOUSE_FILTER_STOP z_index = 128 z_as_relative = false clip_contents = false _search_timer = _make_timer(SEARCH_DELAY, _refresh) _save_timer = _make_timer(SAVE_DELAY, _flush_cfg) _preview_timer = _make_timer(PREVIEW_DELAY, _request_visible_previews) _load_cfg() _update_palette() _setup_style() _build_ui() _apply_saved_state() var base := EditorInterface.get_base_control() if not base.resized.is_connected(_on_editor_resized): base.resized.connect(_on_editor_resized) var fs := EditorInterface.get_resource_filesystem() if not fs.filesystem_changed.is_connected(_on_fs_changed): fs.filesystem_changed.connect(_on_fs_changed) if not fs.resources_reimported.is_connected(_on_reimported): fs.resources_reimported.connect(_on_reimported) if SHOW_HINT: _build_hint(base) func _exit_tree() -> void: _flush_cfg() if _hint_tween and _hint_tween.is_valid(): _hint_tween.kill() if _paste_tween and _paste_tween.is_valid(): _paste_tween.kill() if is_instance_valid(_hint): _hint.queue_free() _hint = null if is_instance_valid(_toast_overlay): _toast_overlay.queue_free() _toast_overlay = null var base := EditorInterface.get_base_control() if is_instance_valid(base) and base.resized.is_connected(_on_editor_resized): base.resized.disconnect(_on_editor_resized) var fs := EditorInterface.get_resource_filesystem() if is_instance_valid(fs): if fs.filesystem_changed.is_connected(_on_fs_changed): fs.filesystem_changed.disconnect(_on_fs_changed) if fs.resources_reimported.is_connected(_on_reimported): fs.resources_reimported.disconnect(_on_reimported) func _make_timer(delay: float, cb: Callable) -> Timer: var t := Timer.new() t.one_shot = true t.wait_time = delay t.timeout.connect(cb) add_child(t) return t func _on_fs_changed() -> void: # Nothing to rebuild while the drawer is closed: we just mark it "dirty". _dirty = true _tree_dirty = true _index_dirty = true if is_open: _refresh() func _on_reimported(paths: PackedStringArray) -> void: for p in paths: _preview_cache.erase(p) _preview_pending.erase(p) _svg_px.erase(p) _svg_fail.erase(p) func _apply_saved_state() -> void: _configure_zoom() _btn_grid.button_pressed = not _view_list _btn_list.button_pressed = _view_list _btn_pin.button_pressed = pinned _btn_details.button_pressed = _details_visible _details_panel.visible = _details_visible ## Drawer palette = the editor's. Backgrounds come from "Base Color" and "Contrast" ## (Godot derives dark_color_1/2/3 from them with base.lerp(black, contrast), contrast * 1.5, contrast * 2). ## We read those colors from the editor theme (they also follow the presets), and fall back to ## computing them from the settings if they are missing. func _update_palette() -> void: var base := EditorInterface.get_base_control() var es := EditorInterface.get_editor_settings() var base_c := Color(0.21, 0.24, 0.29) var contrast := 0.3 if es.has_setting("interface/theme/base_color"): base_c = es.get_setting("interface/theme/base_color") if es.has_setting("interface/theme/contrast"): contrast = float(es.get_setting("interface/theme/contrast")) if base.has_theme_color("base_color", "Editor"): base_c = base.get_theme_color("base_color", "Editor") var black := Color(0, 0, 0, 1) var d1 := base_c.lerp(black, contrast) var d2 := base_c.lerp(black, contrast * 1.5) var d3 := base_c.lerp(black, contrast * 2.0) if base.has_theme_color("dark_color_1", "Editor"): d1 = base.get_theme_color("dark_color_1", "Editor") if base.has_theme_color("dark_color_2", "Editor"): d2 = base.get_theme_color("dark_color_2", "Editor") if base.has_theme_color("dark_color_3", "Editor"): d3 = base.get_theme_color("dark_color_3", "Editor") base_c = _solid(base_c) d1 = _solid(d1) d2 = _solid(d2) d3 = _solid(d3) var font := Color(0.93, 0.94, 0.97) if base_c.get_luminance() < 0.5 else Color(0.10, 0.11, 0.13) if base.has_theme_color("font_color", "Editor"): font = base.get_theme_color("font_color", "Editor") font = _solid(font) _font_color = font # Panels use d1 (the least dark): with a high contrast, d2 / d3 become # almost black. The drawer background and the inset fields use d2, always darker than the panels. COLOR_BG_MAIN = d2 # foundation (the drawer itself) COLOR_HEADER = d1 # top bar COLOR_LEFT_PANEL = d1 # folder tree COLOR_CENTER_PANEL = d1 # asset grid / list COLOR_RIGHT_PANEL = d1 # details panel COLOR_FOOTER = d1 # status bar COLOR_PATH_BG = d2 # inset containers of the top bar COLOR_BORDER = _with_a(base_c.lerp(font, 0.22), 0.65) COLOR_POPUP_BG = _with_a(d1, 0.97) COLOR_CARD_BG = _with_a(d1, 0.96) COLOR_TRACK = base_c.lerp(font, 0.35) TEXT_PRIMARY = font TEXT_SECONDARY = _with_a(font, 0.62) TEXT_MUTED = _with_a(font, 0.38) _palette_sig = _palette_signature() ## Fingerprint of the current theme: lets the plugin know whether the drawer must be rebuilt. func _palette_signature() -> String: var base := EditorInterface.get_base_control() var es := EditorInterface.get_editor_settings() var parts := PackedStringArray() for k in ["interface/theme/base_color", "interface/theme/contrast", "interface/theme/accent_color"]: parts.append(str(es.get_setting(k)) if es.has_setting(k) else "-") for n in ["base_color", "dark_color_1", "dark_color_2", "dark_color_3", "font_color", "accent_color"]: parts.append(str(base.get_theme_color(n, "Editor")) if base.has_theme_color(n, "Editor") else "-") return "|".join(parts) func palette_changed() -> bool: return _palette_signature() != _palette_sig func _solid(c: Color) -> Color: return Color(clampf(c.r, 0.0, 1.0), clampf(c.g, 0.0, 1.0), clampf(c.b, 0.0, 1.0), 1.0) func _with_a(c: Color, a: float) -> Color: return Color(c.r, c.g, c.b, a) ## Translucent overlay in the editor's text color (hovers, separators...). ## Replaces the old Color(1, 1, 1, a), invisible on a light theme. func _ov(a: float) -> Color: return Color(_font_color.r, _font_color.g, _font_color.b, a) func _make_stylebox(bg: Color, border: Color = Color.TRANSPARENT, radius: int = 6, border_w: int = 1) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = bg if border != Color.TRANSPARENT: style.border_color = border style.set_border_width_all(border_w) style.set_corner_radius_all(radius) return style ## Lightens a color for a hovered state — a single setting used everywhere (chips, ## list rows, floating buttons) so the hover "feel" is identical ## across the whole drawer rather than slightly different depending on where you look. func _hover_c(c: Color) -> Color: return c.lightened(0.09) ## Darkens a color for a pressed/active state — counterpart of _hover_c() above. func _press_c(c: Color) -> Color: return c.darkened(0.16) func _setup_style() -> void: var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var style := StyleBoxFlat.new() style.bg_color = COLOR_BG_MAIN # Thin, discreet outline (not a saturated solid line): the drawer must read as a # raised card above the editor, not as a neon-framed window. style.set_border_width_all(1) style.border_color = Color(accent.r, accent.g, accent.b, 0.55) style.set_corner_radius_all(12) # Soft, wide shadow rather than tight: that is what gives the "floating card" feel # of depth in pro UIs, as opposed to a hard edge that reads as a "game window". style.shadow_color = Color(0, 0, 0, 0.30) style.shadow_size = 12 style.shadow_offset = Vector2(0, -12) # shifted upward: nothing sticks out below the drawer add_theme_stylebox_override("panel", style) _panel_style = style func _pad(margin: int, top_bottom: int = -1) -> MarginContainer: var m := MarginContainer.new() var tb := margin if top_bottom < 0 else top_bottom m.add_theme_constant_override("margin_left", margin) m.add_theme_constant_override("margin_right", margin) m.add_theme_constant_override("margin_top", tb) m.add_theme_constant_override("margin_bottom", tb) return m func _build_ui() -> void: var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var root := VBoxContainer.new() root.add_theme_constant_override("separation", 0) add_child(root) _grip = ColorRect.new() _grip.custom_minimum_size = Vector2(0, 6) _grip.color = _ov(0.06) _grip.mouse_default_cursor_shape = Control.CURSOR_VSIZE _grip.gui_input.connect(_on_grip_input) root.add_child(_grip) var pad := _pad(6, 2) pad.size_flags_vertical = Control.SIZE_EXPAND_FILL root.add_child(pad) var main_vbox := VBoxContainer.new() main_vbox.add_theme_constant_override("separation", 3) pad.add_child(main_vbox) # --- HEADER --- var header := PanelContainer.new() header.add_theme_stylebox_override("panel", _make_stylebox(COLOR_HEADER, COLOR_BORDER, 6, 1)) var header_pad := _pad(5, 2) header.add_child(header_pad) header_pad.add_child(_build_toolbar()) main_vbox.add_child(header) # --- SPLIT --- _split = HSplitContainer.new() _split.size_flags_vertical = Control.SIZE_EXPAND_FILL _split.split_offset = 0 main_vbox.add_child(_split) # --- LEFT PANEL --- var left_panel := VBoxContainer.new() left_panel.custom_minimum_size = Vector2(180, 0) left_panel.add_theme_constant_override("separation", 4) # Tabs (Folders / Favorites / Recents / Sets) in their own container var tabs_panel := PanelContainer.new() tabs_panel.add_theme_stylebox_override("panel", _make_stylebox(COLOR_HEADER, COLOR_BORDER, 6, 1)) var tabs_pad := _pad(3) tabs_panel.add_child(tabs_pad) var left_tabs := HBoxContainer.new() left_tabs.add_theme_constant_override("separation", 2) tabs_pad.add_child(left_tabs) left_panel.add_child(tabs_panel) # Tab content: same background for the tree and the lists var left_content := PanelContainer.new() left_content.size_flags_vertical = Control.SIZE_EXPAND_FILL left_content.add_theme_stylebox_override("panel", _make_stylebox(COLOR_LEFT_PANEL, COLOR_BORDER, 6, 1)) var left_pad := _pad(4) left_content.add_child(left_pad) _left_vbox = VBoxContainer.new() left_pad.add_child(_left_vbox) left_panel.add_child(left_content) var tab_group := ButtonGroup.new() var tab_names := [L.t("Dossiers"), L.t("Favoris"), L.t("Récents"), "Sets"] for i in range(tab_names.size()): var btn := Button.new() btn.text = tab_names[i] btn.toggle_mode = true btn.button_group = tab_group btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _style_ghost_button(btn, 4, 3, Color(accent.r, accent.g, accent.b, 0.45)) btn.button_pressed = (i == 0) btn.focus_mode = Control.FOCUS_NONE btn.add_theme_font_size_override("font_size", 11) var tab_idx := i btn.pressed.connect(func() -> void: _switch_left_tab(tab_idx)) left_tabs.add_child(btn) _left_tab_buttons.append(btn) _tree_tools = HBoxContainer.new() _tree_tools.add_theme_constant_override("separation", 2) _left_vbox.add_child(_tree_tools) _add_tree_tool_button("CollapseTree", "⇤", L.t("Tout replier"), _collapse_all_folders) _add_tree_tool_button("ExpandTree", "◎", L.t("Localiser le dossier courant"), _sync_tree_selection) _tree = Tree.new() _tree.hide_root = false _tree.size_flags_vertical = Control.SIZE_EXPAND_FILL _tree.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) _tree.item_selected.connect(_on_tree_selected) _tree.item_collapsed.connect(func(it: TreeItem) -> void: if _syncing_tree: return _collapsed[str(it.get_metadata(0))] = it.collapsed ) _tree.gui_input.connect(_on_tree_gui_input) _tree.set_drag_forwarding(_tree_get_drag, _tree_can_drop, _tree_drop) _tree.draw.connect(_draw_tree_drop) _left_vbox.add_child(_tree) _setup_sticky() _tab_list = ItemList.new() _tab_list.size_flags_vertical = Control.SIZE_EXPAND_FILL _tab_list.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) _tab_list.visible = false _tab_list.item_activated.connect(_on_tab_list_activated) _tab_list.item_clicked.connect(_on_tab_list_clicked) _style_item_list(_tab_list) _left_vbox.add_child(_tab_list) _btn_new_set = Button.new() _btn_new_set.text = L.t("+ Nouveau set") _btn_new_set.visible = false _btn_new_set.pressed.connect(func() -> void: _prompt_string(L.t("Nouveau set"), L.t("mon_set"), func(n: String) -> void: if _valid_name(n) and not _sets.has(n): _sets[n] = [] _save_cfg() _refresh_left_tab_content() ) ) _left_vbox.add_child(_btn_new_set) _split.add_child(left_panel) # --- CENTER AREA --- var center_panel := PanelContainer.new() center_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL center_panel.add_theme_stylebox_override("panel", _make_stylebox(COLOR_CENTER_PANEL, COLOR_BORDER, 6, 1)) var center_pad := _pad(4) center_panel.add_child(center_pad) _list = ItemList.new() _list.size_flags_vertical = Control.SIZE_EXPAND_FILL _list.icon_mode = ItemList.ICON_MODE_TOP _list.max_columns = 0 _list.select_mode = ItemList.SELECT_MULTI _list.same_column_width = true _list.max_text_lines = 2 _list.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) # Keyboard focus keeps working (arrows, activation...), but without the accent frame # that Godot draws by default around the focused control — it looked like an unintended # selection of the whole panel when the drawer opened. _list.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) _list.item_activated.connect(_on_item_activated) _list.draw.connect(_draw_type_bars) _list.draw.connect(_draw_list_overlay) _list.multi_selected.connect(func(_i: int, _s: bool) -> void: _on_selection_changed()) _list.empty_clicked.connect(func(_p: Vector2, _b: int) -> void: _list.deselect_all() _on_selection_changed() ) _list.set_drag_forwarding(_get_drag_data_fw, _list_can_drop, _list_drop) _list.gui_input.connect(_on_list_gui_input) _style_item_list(_list) _list.resized.connect(_schedule_previews) _list.get_v_scroll_bar().value_changed.connect(func(_v: float) -> void: _schedule_previews()) center_pad.add_child(_list) _empty_state = VBoxContainer.new() _empty_state.alignment = BoxContainer.ALIGNMENT_CENTER _empty_state.mouse_filter = Control.MOUSE_FILTER_IGNORE _empty_state.visible = false _empty_lbl = Label.new() _empty_lbl.text = L.t("Aucun asset") _empty_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _empty_lbl.modulate = TEXT_SECONDARY _empty_state.add_child(_empty_lbl) _empty_link = LinkButton.new() _empty_link.text = L.t("Créer un dossier") _empty_link.size_flags_horizontal = Control.SIZE_SHRINK_CENTER _empty_link.add_theme_color_override("font_color", accent) _empty_link.pressed.connect(_create_folder) _empty_state.add_child(_empty_link) _empty_reset = Button.new() _empty_reset.text = L.t("Réinitialiser les filtres") _empty_reset.visible = false _empty_reset.focus_mode = Control.FOCUS_NONE _empty_reset.size_flags_horizontal = Control.SIZE_SHRINK_CENTER var reset_n := _pill_style(accent) var reset_h := _pill_style(accent.lightened(0.15)) for st in [reset_n, reset_h]: (st as StyleBoxFlat).content_margin_left = 12 (st as StyleBoxFlat).content_margin_right = 12 (st as StyleBoxFlat).content_margin_top = 4 (st as StyleBoxFlat).content_margin_bottom = 4 _empty_reset.add_theme_stylebox_override("normal", reset_n) _empty_reset.add_theme_stylebox_override("hover", reset_h) _empty_reset.add_theme_stylebox_override("pressed", reset_n) _empty_reset.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) _empty_reset.add_theme_color_override("font_color", Color.WHITE) _empty_reset.add_theme_color_override("font_hover_color", Color.WHITE) _empty_reset.pressed.connect(func() -> void: _set_filter(0)) _empty_state.add_child(_empty_reset) center_pad.add_child(_empty_state) # Center + details: resizable HSplitContainer (2 children, compatible with Godot 4.2+). var right_split := HSplitContainer.new() right_split.size_flags_horizontal = Control.SIZE_EXPAND_FILL right_split.dragged.connect(_on_details_split_dragged) right_split.add_child(center_panel) _details_panel = _build_details_panel() right_split.add_child(_details_panel) # offset=0 would give the panel (non-expand, second child) its minimum width; # a negative offset gives it back the remembered width (or the default width on first launch). right_split.split_offset = -int(maxf(_details_width - DETAILS_MIN_WIDTH, 0.0)) _split.add_child(right_split) # --- FOOTER --- var footer := PanelContainer.new() footer.add_theme_stylebox_override("panel", _make_stylebox(COLOR_FOOTER, COLOR_BORDER, 6, 1)) var footer_pad := _pad(6, 1) footer.add_child(footer_pad) footer_pad.add_child(_build_footer()) main_vbox.add_child(footer) # Context menu = a simple PanelContainer placed on the editor overlay (rather than a # PopupPanel window): no more square black corners behind the rounded edges. _ctx_popup = PanelContainer.new() _ctx_popup.visible = false _ctx_popup.mouse_filter = Control.MOUSE_FILTER_STOP _ctx_popup.z_index = 350 var ctx_style := _make_stylebox(COLOR_HEADER, COLOR_BORDER, 8, 1) ctx_style.shadow_color = Color(0, 0, 0, 0.55) ctx_style.shadow_size = 10 ctx_style.shadow_offset = Vector2(0, 3) _ctx_popup.add_theme_stylebox_override("panel", ctx_style) var ctx_margin := _pad(8) _ctx_popup.add_child(ctx_margin) _ctx_vbox = VBoxContainer.new() _ctx_vbox.add_theme_constant_override("separation", 2) ctx_margin.add_child(_ctx_vbox) _build_toast() _toast_overlay.add_child(_ctx_popup) _build_paste_pop() ## Small floating "Paste" button that appears near any resource field ## of the Inspector (compatible with the copied resource) when the mouse hovers it — ## like dragging and dropping assets in Unreal's Content Browser. func _build_paste_pop() -> void: var base := EditorInterface.get_base_control() _paste_pop = Button.new() _paste_pop.text = "" _paste_pop.tooltip_text = L.t("Coller la ressource copiée ici") _paste_pop.custom_minimum_size = Vector2(22, 22) _paste_pop.expand_icon = true _paste_pop.visible = false _paste_pop.modulate.a = 0.0 _paste_pop.focus_mode = Control.FOCUS_NONE _paste_pop.mouse_filter = Control.MOUSE_FILTER_STOP _paste_pop.z_index = 200 # above the drawer panel itself (z_index = 128) if base.has_theme_icon("ActionPaste", "EditorIcons"): _paste_pop.icon = base.get_theme_icon("ActionPaste", "EditorIcons") elif base.has_theme_icon("ActionCopy", "EditorIcons"): _paste_pop.icon = base.get_theme_icon("ActionCopy", "EditorIcons") _paste_pop.add_theme_color_override("icon_normal_color", Color.WHITE) _paste_pop.add_theme_color_override("icon_hover_color", Color.WHITE) _paste_pop.add_theme_color_override("icon_pressed_color", Color.WHITE) _paste_pop.add_theme_stylebox_override("normal", _make_stylebox(Color(COLOR_RES_ACTION.r, COLOR_RES_ACTION.g, COLOR_RES_ACTION.b, 0.95), Color(1, 1, 1, 0.55), 5, 1)) _paste_pop.add_theme_stylebox_override("hover", _make_stylebox(Color(COLOR_RES_ACTION.r, COLOR_RES_ACTION.g, COLOR_RES_ACTION.b, 1.0), Color(1, 1, 1, 0.85), 5, 1)) _paste_pop.add_theme_stylebox_override("pressed", _make_stylebox(_press_c(COLOR_RES_ACTION), Color.WHITE, 5, 1)) _paste_pop.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) _paste_pop.pressed.connect(_on_paste_pop_pressed) _add_press_bounce(_paste_pop, 1.12, 0.88) _toast_overlay.add_child(_paste_pop) _build_copy_pop() ## Small floating "Copy" button (icon only, same green as "Paste") that appears when ## hovering a resource in the drawer's list — counterpart of the "Paste" button on the Inspector side. func _build_copy_pop() -> void: var base := EditorInterface.get_base_control() _copy_pop = Button.new() _copy_pop.text = "" _copy_pop.tooltip_text = L.t("Copier la ressource") _copy_pop.custom_minimum_size = Vector2(22, 22) _copy_pop.expand_icon = true _copy_pop.visible = false _copy_pop.modulate.a = 0.0 _copy_pop.focus_mode = Control.FOCUS_NONE _copy_pop.mouse_filter = Control.MOUSE_FILTER_STOP _copy_pop.z_index = 200 # above the drawer panel itself (z_index = 128) if base.has_theme_icon("ActionCopy", "EditorIcons"): _copy_pop.icon = base.get_theme_icon("ActionCopy", "EditorIcons") elif base.has_theme_icon("Duplicate", "EditorIcons"): _copy_pop.icon = base.get_theme_icon("Duplicate", "EditorIcons") _copy_pop.add_theme_color_override("icon_normal_color", Color.WHITE) _copy_pop.add_theme_color_override("icon_hover_color", Color.WHITE) _copy_pop.add_theme_color_override("icon_pressed_color", Color.WHITE) _copy_pop.add_theme_stylebox_override("normal", _make_stylebox(Color(COLOR_RES_ACTION.r, COLOR_RES_ACTION.g, COLOR_RES_ACTION.b, 0.95), Color(1, 1, 1, 0.55), 5, 1)) _copy_pop.add_theme_stylebox_override("hover", _make_stylebox(Color(COLOR_RES_ACTION.r, COLOR_RES_ACTION.g, COLOR_RES_ACTION.b, 1.0), Color(1, 1, 1, 0.85), 5, 1)) _copy_pop.add_theme_stylebox_override("pressed", _make_stylebox(_press_c(COLOR_RES_ACTION), Color.WHITE, 5, 1)) _copy_pop.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) _copy_pop.pressed.connect(_on_copy_pop_pressed) _add_press_bounce(_copy_pop, 1.12, 0.88) _toast_overlay.add_child(_copy_pop) func _build_toast() -> void: var base := EditorInterface.get_base_control() # Added at the editor level (like the hint pill), not as a child of the drawer: # it must stay visible even when the drawer is closed (e.g. after an Alt+V paste). _toast_overlay = Control.new() _toast_overlay.name = "AssetDrawerToastOverlay" _toast_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE _toast_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) base.add_child(_toast_overlay) # Strip centered at the bottom of the editor that hosts the toast (auto-centered by the CenterContainer). var bottom_bar := CenterContainer.new() bottom_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE bottom_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) bottom_bar.offset_top = -58 bottom_bar.offset_bottom = -14 _toast_overlay.add_child(bottom_bar) _toast = PanelContainer.new() _toast.mouse_filter = Control.MOUSE_FILTER_IGNORE _toast.modulate.a = 0.0 # Must stay readable even above the "Ctrl+Space" pill (z_index = 200) and the # drawer panel (z_index = 128), since both occupy the same area at the bottom of the screen. _toast.z_index = 300 var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") _toast.add_theme_stylebox_override("panel", _make_stylebox(COLOR_POPUP_BG, Color(accent.r, accent.g, accent.b, 0.85), 8, 1)) var pad := _pad(14, 7) _toast.add_child(pad) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) pad.add_child(row) _toast_lbl = Label.new() _toast_lbl.add_theme_font_size_override("font_size", 12) row.add_child(_toast_lbl) bottom_bar.add_child(_toast) func _build_toolbar() -> Control: var wrap := VBoxContainer.new() wrap.add_theme_constant_override("separation", 2) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 5) wrap.add_child(row) # Top bar: each group in a dark container of the same height, same outline, same corners var accent_bar: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") # 1) Navigation var nav_row := HBoxContainer.new() nav_row.add_theme_constant_override("separation", 2) row.add_child(_bar_panel(nav_row)) _btn_back = _icon_btn("Back", "<") _btn_back.tooltip_text = L.t("Précédent (bouton souris 4)") _btn_back.pressed.connect(func() -> void: _hist_go(-1)) nav_row.add_child(_btn_back) _btn_fwd = _icon_btn("Forward", ">") _btn_fwd.tooltip_text = L.t("Suivant (bouton souris 5)") _btn_fwd.pressed.connect(func() -> void: _hist_go(1)) nav_row.add_child(_btn_fwd) _btn_up = _icon_btn("ArrowUp", "^") _btn_up.tooltip_text = L.t("Dossier parent (Retour arrière)") _btn_up.pressed.connect(_go_up) nav_row.add_child(_btn_up) # 2) Breadcrumb _breadcrumbs = HBoxContainer.new() _breadcrumbs.size_flags_horizontal = Control.SIZE_EXPAND_FILL _breadcrumbs.clip_contents = true row.add_child(_bar_panel(_breadcrumbs, true)) # 3) Search: same background / outline as the containers _search = LineEdit.new() _search.placeholder_text = L.t("Rechercher... (Ctrl+F)") _search.clear_button_enabled = true _search.right_icon = EditorInterface.get_base_control().get_theme_icon("Search", "EditorIcons") _search.custom_minimum_size = Vector2(170, BAR_H) var search_box := _topbar_stylebox() search_box.content_margin_left = 8 search_box.content_margin_right = 6 search_box.content_margin_top = 2 search_box.content_margin_bottom = 2 var search_focus := search_box.duplicate() as StyleBoxFlat search_focus.border_color = Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.9) _search.add_theme_stylebox_override("normal", search_box) _search.add_theme_stylebox_override("focus", search_focus) _search.add_theme_stylebox_override("read_only", search_box) _search.text_changed.connect(func(_t: String) -> void: _search_timer.start()) row.add_child(_search) # Search scope: whole project / current folder _btn_scope = _icon_btn("Filesystem,Folder", L.t("Projet")) _btn_scope.toggle_mode = true _btn_scope.button_pressed = _search_all _btn_scope.toggled.connect(_on_scope_toggled) _style_ghost_button(_btn_scope, 5, 2, Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.45)) row.add_child(_bar_panel(_btn_scope)) _update_search_ui() # 4) Display mode (grid / list) var view_box := HBoxContainer.new() view_box.add_theme_constant_override("separation", 2) row.add_child(_bar_panel(view_box)) var view_group := ButtonGroup.new() _btn_grid = _icon_btn("FileThumbnail,Grid,TileMap", L.t("Grille")) _btn_grid.tooltip_text = L.t("Vue grille") _btn_grid.toggle_mode = true _btn_grid.button_group = view_group _btn_grid.pressed.connect(func() -> void: _view_list = false _configure_zoom() _save_cfg() _refresh() ) view_box.add_child(_btn_grid) _btn_list = _icon_btn("FileList", L.t("Liste")) _btn_list.tooltip_text = L.t("Vue liste") _btn_list.toggle_mode = true _btn_list.button_group = view_group _btn_list.pressed.connect(func() -> void: _view_list = true _configure_zoom() _save_cfg() _refresh() ) view_box.add_child(_btn_list) _style_ghost_button(_btn_grid, 5, 2, Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.45)) _style_ghost_button(_btn_list, 5, 2, Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.45)) # 5) Zoom _zoom = HSlider.new() _zoom.min_value = 48 _zoom.max_value = 160 _zoom.step = 8 _zoom.value = 80 _zoom.tooltip_text = L.t("Taille des miniatures (Ctrl+molette)") _zoom.custom_minimum_size.x = 80 _zoom.size_flags_vertical = Control.SIZE_SHRINK_CENTER _zoom.value_changed.connect(_on_zoom_changed) _style_zoom_slider(accent_bar) row.add_child(_bar_panel(_zoom, false, 8)) # 6) Actions: details, pin, dock, close var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 2) row.add_child(_bar_panel(actions)) _btn_details = _icon_btn("ControlLayout", L.t("Détails")) _btn_details.tooltip_text = L.t("Panneau de détails") _btn_details.toggle_mode = true _btn_details.toggled.connect(func(on: bool) -> void: _details_visible = on _details_panel.visible = on _save_cfg() ) _style_ghost_button(_btn_details, 5, 2, Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.45)) actions.add_child(_btn_details) _btn_pin = _icon_btn("Pin", L.t("Épingler")) _btn_pin.tooltip_text = L.t("Épingler (reste ouvert)") _btn_pin.toggle_mode = true _btn_pin.toggled.connect(func(on: bool) -> void: pinned = on _save_cfg() ) _style_ghost_button(_btn_pin, 5, 2, Color(accent_bar.r, accent_bar.g, accent_bar.b, 0.45)) actions.add_child(_btn_pin) var dock_btn := Button.new() dock_btn.text = "DOCK" dock_btn.tooltip_text = L.t("Afficher ce dossier dans le dock Fichiers") dock_btn.focus_mode = Control.FOCUS_NONE dock_btn.add_theme_font_size_override("font_size", 10) _style_ghost_button(dock_btn, 6, 2) dock_btn.pressed.connect(func() -> void: _show_in_dock(current_dir)) actions.add_child(dock_btn) var close_btn := _icon_btn("Close", "X") close_btn.tooltip_text = L.t("Fermer (Échap)") close_btn.pressed.connect(close) actions.add_child(close_btn) # Row 2: filters var filters_box := HBoxContainer.new() filters_box.add_theme_constant_override("separation", 3) var filters := [L.t("Tout"), L.t("Scènes"), "Scripts", L.t("Modèles"), "Images", "Audio", "Shaders"] for i in range(filters.size()): var btn := Button.new() btn.text = filters[i] btn.focus_mode = Control.FOCUS_NONE btn.add_theme_font_size_override("font_size", 11) var idx := i btn.pressed.connect(func() -> void: _set_filter(idx)) _add_press_bounce(btn, 1.06, 0.92) _filter_buttons.append(btn) filters_box.add_child(btn) wrap.add_child(filters_box) _update_filter_pills() return wrap func _build_details_panel() -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(DETAILS_MIN_WIDTH, 0) panel.add_theme_stylebox_override("panel", _make_stylebox(COLOR_RIGHT_PANEL, COLOR_BORDER, 6, 1)) # Everything is in a ScrollContainer: the panel can be very short, so it scrolls. # When there is no more room, the buttons switch to a single row of icon-only buttons. _details_scroll = ScrollContainer.new() _details_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _details_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO _details_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL _details_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _details_scroll.resized.connect(_update_details_layout) panel.add_child(_details_scroll) var margin := _pad(5) margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL margin.size_flags_vertical = Control.SIZE_EXPAND_FILL _details_scroll.add_child(margin) var dv := VBoxContainer.new() dv.add_theme_constant_override("separation", 3) margin.add_child(dv) _details_dv = dv # --- Header: framed thumbnail on the left; on the right the name then chips (type, size, date) --- var head := HBoxContainer.new() head.add_theme_constant_override("separation", 6) dv.add_child(head) # Frameless thumbnail: a small drop shadow is drawn behind the image itself # (slightly enlarged black silhouettes that get more and more transparent = soft shadow). var thumb_pad := _pad(4) thumb_pad.size_flags_vertical = Control.SIZE_SHRINK_BEGIN head.add_child(thumb_pad) var thumb_stack := Control.new() thumb_stack.custom_minimum_size = Vector2(44, 44) thumb_pad.add_child(thumb_stack) for layer in [[3.0, 0.07], [2.0, 0.09], [1.0, 0.12]]: var e: float = layer[0] var sh := TextureRect.new() sh.expand_mode = TextureRect.EXPAND_IGNORE_SIZE sh.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED sh.mouse_filter = Control.MOUSE_FILTER_IGNORE sh.self_modulate = Color(0, 0, 0, layer[1]) sh.anchor_right = 1.0 sh.anchor_bottom = 1.0 sh.offset_left = -e sh.offset_right = e sh.offset_top = -e + 2.0 sh.offset_bottom = e + 2.0 thumb_stack.add_child(sh) _preview_shadows.append(sh) _preview = TextureRect.new() _preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE _preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED _preview.anchor_right = 1.0 _preview.anchor_bottom = 1.0 thumb_stack.add_child(_preview) var head_txt := VBoxContainer.new() head_txt.size_flags_horizontal = Control.SIZE_EXPAND_FILL head_txt.size_flags_vertical = Control.SIZE_SHRINK_CENTER head_txt.add_theme_constant_override("separation", 1) head.add_child(head_txt) _details_name = Label.new() _details_name.add_theme_font_override("font", EditorInterface.get_base_control().get_theme_font("bold", "EditorFonts")) _details_name.add_theme_font_size_override("font_size", 12) _details_name.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _details_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL head_txt.add_child(_details_name) var accent_h: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") _details_type_row = HFlowContainer.new() _details_type_row.add_theme_constant_override("h_separation", 2) _details_type_row.add_theme_constant_override("v_separation", 1) head_txt.add_child(_details_type_row) _lbl_type = Label.new() _lbl_size = Label.new() _lbl_modified = Label.new() _chip_type = _make_chip(_lbl_type, Color(accent_h.r, accent_h.g, accent_h.b, 0.25)) _chip_size = _make_chip(_lbl_size, _ov(0.09)) _chip_date = _make_chip(_lbl_modified, _ov(0.09)) _details_type_row.add_child(_chip_type) _details_type_row.add_child(_chip_size) _details_type_row.add_child(_chip_date) # --- Separator + inset info card: path, UID (hidden if there is nothing to show) --- _details_sep_info = _detail_sep() dv.add_child(_details_sep_info) _details_card = PanelContainer.new() _details_card.add_theme_stylebox_override("panel", _make_stylebox(COLOR_PATH_BG, COLOR_BORDER.darkened(0.25), 6, 1)) dv.add_child(_details_card) var card_pad := _pad(6, 3) _details_card.add_child(card_pad) var card_box := VBoxContainer.new() card_box.add_theme_constant_override("separation", 3) card_pad.add_child(card_box) _details_path = Label.new() _field_path = _make_field(L.t("Chemin"), _details_path) card_box.add_child(_field_path) _details_card_sep = _detail_sep() card_box.add_child(_details_card_sep) _lbl_uid = Label.new() _field_uid = _make_field("UID", _lbl_uid) card_box.add_child(_field_uid) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL dv.add_child(spacer) # --- Separator before the actions area --- dv.add_child(_detail_sep()) # --- Buttons: column with text + icon, or a row of icon-only buttons when space is short --- _details_btn_box = BoxContainer.new() _details_btn_box.vertical = true _details_btn_box.add_theme_constant_override("separation", 3) dv.add_child(_details_btn_box) _btn_labels.clear() var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") _btn_open = _detail_btn("Load,Folder", L.t("Ouvrir")) _btn_open.add_theme_stylebox_override("normal", _make_stylebox(accent, Color.TRANSPARENT, 4)) _btn_open.add_theme_stylebox_override("hover", _make_stylebox(_hover_c(accent), Color.TRANSPARENT, 4)) _btn_open.add_theme_stylebox_override("pressed", _make_stylebox(_press_c(accent), Color.TRANSPARENT, 4)) _tint_btn_icon(_btn_open, Color.WHITE) _btn_open.pressed.connect(func() -> void: _open_path(_context_path)) _details_btn_box.add_child(_btn_open) _btn_copy_res = _detail_btn("ActionCopy,Duplicate", L.t("Copier la ressource"), L.t("Puis survolez un champ ressource de l'Inspecteur (ex. Mesh d'un MeshInstance3D) : un bouton « Coller » apparaît (ou Alt+V au clavier)")) # Same green as the "Paste resource" button (COLOR_RES_ACTION): the two actions # form a visual pair (copy here → paste over there). _btn_copy_res.add_theme_stylebox_override("normal", _make_stylebox(COLOR_RES_ACTION, Color.TRANSPARENT, 4)) _btn_copy_res.add_theme_stylebox_override("hover", _make_stylebox(_hover_c(COLOR_RES_ACTION), Color(1, 1, 1, 0.55), 4, 1)) _btn_copy_res.add_theme_stylebox_override("pressed", _make_stylebox(_press_c(COLOR_RES_ACTION), Color.WHITE, 4, 1)) _btn_copy_res.add_theme_color_override("font_color", Color.WHITE) _btn_copy_res.add_theme_color_override("font_hover_color", Color.WHITE) _btn_copy_res.add_theme_color_override("font_pressed_color", Color.WHITE) _tint_btn_icon(_btn_copy_res, Color.WHITE) _btn_copy_res.resized.connect(func() -> void: _btn_copy_res.pivot_offset = _btn_copy_res.size * 0.5) _btn_copy_res.pressed.connect(func() -> void: _copy_resource_to_clipboard(_context_path) _bounce_to(_btn_copy_res, Vector2(1.08, 1.08), 0.08) get_tree().create_timer(0.08).timeout.connect(func() -> void: _bounce_to(_btn_copy_res, Vector2.ONE, 0.14)) ) _details_btn_box.add_child(_btn_copy_res) _btn_copy_path = _detail_btn("CopyNodePath,ActionCopy,Link", L.t("Copier le chemin")) _btn_copy_path.pressed.connect(func() -> void: DisplayServer.clipboard_set(_context_path) _flash(L.t("Chemin copié")) ) _details_btn_box.add_child(_btn_copy_path) _btn_fav = _detail_btn("NonFavorite,Favorites", L.t("Ajouter aux favoris")) _btn_fav.resized.connect(func() -> void: _btn_fav.pivot_offset = _btn_fav.size * 0.5) _btn_fav.pressed.connect(func() -> void: var was_fav := _is_fav(_context_path) _toggle_favorite(_context_path) _update_details(_context_path) _refresh() _bounce_to(_btn_fav, Vector2(1.15, 1.15), 0.1) get_tree().create_timer(0.1).timeout.connect(func() -> void: _bounce_to(_btn_fav, Vector2.ONE, 0.14)) _flash(L.t("Retiré des favoris") if was_fav else L.t("Ajouté aux favoris")) ) _details_btn_box.add_child(_btn_fav) _sync_details_meta() return panel ## Rounded chip (type, size, date) around a Label. func _make_chip(lbl: Label, tint: Color) -> PanelContainer: var chip := PanelContainer.new() chip.add_theme_stylebox_override("panel", _make_stylebox(tint, Color.TRANSPARENT, 4)) var pad := _pad(2, 0) # Negative vertical margins: removes the line spacing the Label adds around the text pad.add_theme_constant_override("margin_top", -2) pad.add_theme_constant_override("margin_bottom", -2) chip.add_child(pad) lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.add_theme_font_size_override("font_size", 9) lbl.add_theme_constant_override("line_spacing", 0) lbl.add_theme_constant_override("outline_size", 0) pad.add_child(lbl) return chip ## "Discreet title above, value below" field: readable even for long values. func _make_field(title: String, val: Label) -> VBoxContainer: var f := VBoxContainer.new() f.add_theme_constant_override("separation", 1) var t := Label.new() t.text = title t.modulate = TEXT_MUTED t.add_theme_font_size_override("font_size", 9) f.add_child(t) val.add_theme_font_size_override("font_size", 10) val.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART val.size_flags_horizontal = Control.SIZE_EXPAND_FILL f.add_child(val) return f ## Adds invisible break points (zero-width space) after _ . / - ## so that long names wrap cleanly instead of in the middle of a word. func _soft_wrap(t: String) -> String: var zw := char(0x200B) return t.replace("_", "_" + zw).replace(".", "." + zw).replace("/", "/" + zw).replace("-", "-" + zw) ## Thin separator line between the zones of the details panel. func _detail_sep() -> ColorRect: var r := ColorRect.new() r.custom_minimum_size = Vector2(0, 1) r.color = COLOR_BORDER r.mouse_filter = Control.MOUSE_FILTER_IGNORE return r ## Details panel button: icon (first name found in the list) + label + tooltip. func _detail_btn(icon_names: String, label: String, tip: String = "") -> Button: var b := Button.new() b.size_flags_horizontal = Control.SIZE_EXPAND_FILL b.expand_icon = false b.add_theme_font_size_override("font_size", 11) b.icon = _icon_from(icon_names) _btn_labels[b] = [label, tip] _apply_btn_mode(b) return b func _icon_from(names: String) -> Texture2D: var base := EditorInterface.get_base_control() for n in names.split(","): if base.has_theme_icon(n, "EditorIcons"): return base.get_theme_icon(n, "EditorIcons") return null func _tint_btn_icon(b: Button, c: Color) -> void: for n in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color", "icon_hover_pressed_color"]: b.add_theme_color_override(n, c) func _untint_btn_icon(b: Button) -> void: for n in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color", "icon_hover_pressed_color"]: b.remove_theme_color_override(n) func _set_btn_label(b: Button, label: String) -> void: var d: Array = _btn_labels.get(b, ["", ""]) d[0] = label _btn_labels[b] = d _apply_btn_mode(b) ## Text + icon on the left (normal mode) or centered icon only, text as tooltip (compact mode). func _apply_btn_mode(b: Button) -> void: var d: Array = _btn_labels.get(b, ["", ""]) var label: String = d[0] var tip: String = d[1] if _details_compact: b.text = "" b.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER b.tooltip_text = label if tip == "" else label + "\n" + tip else: b.text = label b.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT b.tooltip_text = tip func _set_fav_state(path: String) -> void: var fav := _is_fav(path) _set_btn_label(_btn_fav, L.t("Retirer des favoris") if fav else L.t("Ajouter aux favoris")) _btn_fav.icon = _icon_from("Favorites") if fav else _icon_from("NonFavorite,Favorites") if fav: _tint_btn_icon(_btn_fav, COLOR_FAV) else: _untint_btn_icon(_btn_fav) ## Hides empty info (« - ») to keep only what is useful. func _sync_details_meta() -> void: if _details_type_row == null: return var has_type := _lbl_type.text != "" and _lbl_type.text != "-" var has_size := _lbl_size.text != "" and _lbl_size.text != "-" var has_date := _lbl_modified.text != "" and _lbl_modified.text != "-" _chip_type.visible = has_type _chip_size.visible = has_size _chip_date.visible = has_date _details_type_row.visible = has_type or has_size or has_date var path_on := _details_path.text != "" and _details_path.visible var uid_on := _lbl_uid.text != "" and _lbl_uid.text != "-" _field_path.visible = path_on _field_uid.visible = uid_on _details_card_sep.visible = path_on and uid_on _details_card.visible = path_on or uid_on _details_sep_info.visible = _details_card.visible ## Switches between buttons in a column (text + icon) and a row of icons depending on the available room. ## The height needed for normal mode is recomputed every time (no remembered value): ## current content height, minus the current button area, plus the stacked buttons. func _update_details_layout() -> void: if _details_scroll == null or _details_dv == null: return var avail := _details_scroll.size.y if avail <= 0.0 or _details_scroll.size.x < 60.0: return # not laid out yet var n := 0 for b in _btn_labels.keys(): if b.visible: n += 1 if n == 0: return var sep := float(_details_btn_box.get_theme_constant("separation")) var box_h := _details_btn_box.get_combined_minimum_size().y var btn_h := _btn_open.get_combined_minimum_size().y var need := _details_dv.get_combined_minimum_size().y - box_h + n * btn_h + (n - 1) * sep + 12.0 if not _details_compact: if avail < need - 0.5: _set_details_compact(true) elif avail >= need + 2.0: _set_details_compact(false) func _set_details_compact(on: bool) -> void: if _details_compact == on: return _details_compact = on _details_btn_box.vertical = not on for b in _btn_labels.keys(): _apply_btn_mode(b) func _build_footer() -> Control: var footer := HBoxContainer.new() _status = Label.new() _status.modulate = TEXT_SECONDARY _status.add_theme_font_size_override("font_size", 12) _status.size_flags_horizontal = Control.SIZE_EXPAND_FILL _status.clip_text = true footer.add_child(_status) var btn_f := _icon_btn("Folder", L.t("+Dossier")) btn_f.tooltip_text = L.t("Nouveau dossier") btn_f.pressed.connect(_create_folder) footer.add_child(btn_f) var btn_sc := Button.new() btn_sc.text = L.t("+Scène") _style_ghost_button(btn_sc, 6, 2) btn_sc.pressed.connect(_create_scene) footer.add_child(btn_sc) var btn_st := Button.new() btn_st.text = "+Script" _style_ghost_button(btn_st, 6, 2) btn_st.pressed.connect(_create_script) footer.add_child(btn_st) return footer func _topbar_stylebox() -> StyleBoxFlat: return _make_stylebox(COLOR_PATH_BG, COLOR_BORDER.darkened(0.25), 6, 1) # Uniform dark container of the top bar (same height, outline and corners) func _bar_panel(inner: Control, expand: bool = false, pad_h: int = 3) -> PanelContainer: var p := PanelContainer.new() p.custom_minimum_size = Vector2(0, BAR_H) p.add_theme_stylebox_override("panel", _topbar_stylebox()) if expand: p.size_flags_horizontal = Control.SIZE_EXPAND_FILL var pad := _pad(pad_h, 2) p.add_child(pad) pad.add_child(inner) return p func _icon_btn(icon_name: String, fallback: String) -> Button: # icon_name can contain several names separated by commas: the first existing one is used var b := Button.new() b.focus_mode = Control.FOCUS_NONE _style_ghost_button(b, 5, 2) var base := EditorInterface.get_base_control() var found := false for n in icon_name.split(","): if base.has_theme_icon(n, "EditorIcons"): b.icon = base.get_theme_icon(n, "EditorIcons") found = true break if not found: b.text = fallback return b func _ghost_box(bg: Color, h: int, v: int) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = bg s.set_corner_radius_all(5) s.content_margin_left = h s.content_margin_right = h s.content_margin_top = v s.content_margin_bottom = v return s func _style_ghost_button(b: Button, h: int = 6, v: int = 3, active_bg: Color = Color.TRANSPARENT) -> void: if active_bg.a <= 0.0: active_bg = _ov(0.18) # Transparent at rest, "panel" on hover; active state (toggle button) more pronounced. # flat must stay false, otherwise Godot draws no background. b.flat = false b.add_theme_stylebox_override("normal", _ghost_box(Color(1, 1, 1, 0.0), h, v)) b.add_theme_stylebox_override("hover", _ghost_box(_ov(0.10), h, v)) b.add_theme_stylebox_override("pressed", _ghost_box(active_bg, h, v)) b.add_theme_stylebox_override("hover_pressed", _ghost_box(active_bg.lightened(0.1), h, v)) b.add_theme_stylebox_override("disabled", _ghost_box(Color(1, 1, 1, 0.0), h, v)) b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) _add_press_bounce(b) ## Micro-interaction shared by all "ghost" buttons: slight zoom on hover, ## small "squash" on click. Gives the drawer a livelier, more responsive feel. func _add_press_bounce(b: Button, hover_scale: float = 1.08, press_scale: float = 0.9) -> void: b.pivot_offset = b.size * 0.5 b.resized.connect(func() -> void: b.pivot_offset = b.size * 0.5) b.mouse_entered.connect(func() -> void: _bounce_to(b, Vector2.ONE * hover_scale, 0.12)) b.mouse_exited.connect(func() -> void: _bounce_to(b, Vector2.ONE, 0.16)) b.button_down.connect(func() -> void: _bounce_to(b, Vector2.ONE * press_scale, 0.06)) b.button_up.connect(func() -> void: var target := Vector2.ONE * hover_scale if b.is_hovered() else Vector2.ONE _bounce_to(b, target, 0.14) ) func _bounce_to(node: Control, target: Vector2, dur: float) -> void: if not is_instance_valid(node): return var tw := node.get_meta("_bounce_tween", null) if tw != null and (tw as Tween).is_valid(): (tw as Tween).kill() var t := create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) t.tween_property(node, "scale", target, dur) node.set_meta("_bounce_tween", t) ## Softer selection/hover highlight (rounded corners + slight accent glow) ## than the default square rectangle of the ItemList. func _style_item_list(list: ItemList) -> void: var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") # Selection: tinted background + crisp 2 px outline + slight glow (the ✓ badge is drawn on top). var sel := _make_stylebox(Color(accent.r, accent.g, accent.b, 0.20), Color(accent.r, accent.g, accent.b, 0.95), 8, 2) sel.shadow_color = Color(accent.r, accent.g, accent.b, 0.35) sel.shadow_size = 5 list.add_theme_stylebox_override("selected", sel) list.add_theme_stylebox_override("selected_focus", sel) list.add_theme_stylebox_override("cursor", _make_stylebox(Color.TRANSPARENT, Color(accent.r, accent.g, accent.b, 0.45), 8, 1)) list.add_theme_stylebox_override("cursor_unfocused", StyleBoxEmpty.new()) # "hovered" only exists from Godot 4.3; silently ignored on earlier versions. list.add_theme_stylebox_override("hovered", _make_stylebox(_ov(0.07), _ov(0.14), 8, 1)) func _update_nav_buttons() -> void: if _btn_back == null: return _btn_back.disabled = _history_i <= 0 _btn_fwd.disabled = _history_i >= _history.size() - 1 _btn_up.disabled = _active_set.is_empty() and current_dir == "res://" # ---------- Filters & tabs ---------- func _set_filter(idx: int) -> void: _selected_filter = idx _update_filter_pills() _refresh() func _update_filter_pills() -> void: var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") for i in range(_filter_buttons.size()): var b := _filter_buttons[i] var active := (i == _selected_filter) b.add_theme_stylebox_override("normal", _pill_style(accent if active else _ov(0.05))) b.add_theme_stylebox_override("hover", _pill_style(accent.lightened(0.15) if active else _ov(0.12))) b.add_theme_stylebox_override("pressed", _pill_style(accent)) b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) if active: b.add_theme_color_override("font_color", Color.WHITE) b.add_theme_color_override("font_hover_color", Color.WHITE) else: b.remove_theme_color_override("font_color") b.remove_theme_color_override("font_hover_color") func _pill_style(bg: Color) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = bg s.set_corner_radius_all(9) s.content_margin_left = 8 s.content_margin_right = 8 s.content_margin_top = 1 s.content_margin_bottom = 1 return s func _switch_left_tab(idx: int) -> void: _active_left_tab = idx _tree.visible = (idx == 0) _tree_tools.visible = (idx == 0) _tab_list.visible = (idx != 0) _btn_new_set.visible = (idx == 3) _refresh_left_tab_content() func _refresh_left_tab_content() -> void: if _tree == null: return if _active_left_tab == 0: if _tree_dirty: _refresh_tree() else: _sync_tree_selection() return var base := EditorInterface.get_base_control() _tab_list.clear() if _active_left_tab == 3: for n in _sets.keys(): var idx := _tab_list.add_item("%s (%d)" % [n, (_sets[n] as Array).size()], base.get_theme_icon("Groups", "EditorIcons")) _tab_list.set_item_metadata(idx, str(n)) _tab_list.set_item_tooltip(idx, L.t("Clic droit : supprimer le set")) return if _active_left_tab == 1: _sync_favorites() var src: PackedStringArray = _favorites if _active_left_tab == 1 else _recents var colors: Dictionary = ProjectSettings.get_setting("file_customization/folder_colors", {}) for path in src: var is_dir := DirAccess.dir_exists_absolute(path) var idx := _tab_list.add_item(path.get_file() if path != "res://" else "res://", base.get_theme_icon("Folder" if is_dir else "File", "EditorIcons")) _tab_list.set_item_metadata(idx, path) _tab_list.set_item_tooltip(idx, path + (L.t("\nClic droit : retirer") if _active_left_tab == 1 else "")) if is_dir: var cn := _resolve_color(colors, path) if cn in FOLDER_COLORS: _tab_list.set_item_icon_modulate(idx, FOLDER_COLORS[cn]) func _on_tab_list_activated(idx: int) -> void: var meta := str(_tab_list.get_item_metadata(idx)) if _active_left_tab == 3: _active_set = meta _search.text = "" _refresh() else: _open_path(meta) func _on_tab_list_clicked(idx: int, _at: Vector2, btn: int) -> void: if btn != MOUSE_BUTTON_RIGHT: return var meta := str(_tab_list.get_item_metadata(idx)) if _active_left_tab == 1: _toggle_favorite(meta) _refresh() elif _active_left_tab == 3: _confirm(L.t("Supprimer le set"), L.t("Supprimer le set « %s » ? (les fichiers ne sont pas touchés)") % meta, func() -> void: _sets.erase(meta) if _active_set == meta: _active_set = "" _save_cfg() _refresh() ) # ---------- Anti-blur HD rendering ---------- func _sd_rounded_box(p: Vector2, b: Vector2, r: float) -> float: var q := Vector2(absf(p.x), absf(p.y)) - b + Vector2(r, r) return minf(maxf(q.x, q.y), 0.0) + Vector2(maxf(q.x, 0.0), maxf(q.y, 0.0)).length() - r func _bucket(size: int) -> int: for b in ICON_BUCKETS: if size <= int(b): return int(b) return int(ICON_BUCKETS[ICON_BUCKETS.size() - 1]) func _get_hd_folder_icon(size: int) -> Texture2D: # Grayscale icon: the folder color is applied by modulation # (a single texture per size instead of one per color). var cache_key := "folder_%d" % size if _hd_icon_cache.has(cache_key): return _hd_icon_cache[cache_key] var img := Image.create(size, size, false, Image.FORMAT_RGBA8) var S := float(size) var tab_center := Vector2(S * 0.32, S * 0.26) var tab_size := Vector2(S * 0.22, S * 0.09) var tab_radius := S * 0.05 var body_center := Vector2(S * 0.50, S * 0.58) var body_size := Vector2(S * 0.42, S * 0.28) var body_radius := S * 0.07 var tab_color := Color(0.78, 0.78, 0.78) # = darkened(0.22) once tinted var body_color := Color.WHITE for y in range(size): for x in range(size): var p := Vector2(x + 0.5, y + 0.5) var d_tab := _sd_rounded_box(p - tab_center, tab_size, tab_radius) var d_body := _sd_rounded_box(p - body_center, body_size, body_radius) var a_tab := clampf(0.5 - d_tab, 0.0, 1.0) var a_body := clampf(0.5 - d_body, 0.0, 1.0) var c := Color(0, 0, 0, 0) if a_tab > 0.0: c = Color(tab_color.r, tab_color.g, tab_color.b, a_tab) if a_body > 0.0: c = c.blend(Color(body_color.r, body_color.g, body_color.b, a_body)) img.set_pixel(x, y, c) var tex := ImageTexture.create_from_image(img) _hd_icon_cache[cache_key] = tex return tex func _get_card_image(size: int) -> Image: # Common "card" background for all file types, computed only once per size if _card_images.has(size): return _card_images[size] var img := Image.create(size, size, false, Image.FORMAT_RGBA8) var S := float(size) var card_center := Vector2(S * 0.50, S * 0.52) var card_size := Vector2(S * 0.36, S * 0.40) var card_radius := S * 0.08 var card_color := Color(0.16, 0.20, 0.26, 0.95) var border_color := Color(0.35, 0.45, 0.60, 0.8) for y in range(size): for x in range(size): var p := Vector2(x + 0.5, y + 0.5) var d := _sd_rounded_box(p - card_center, card_size, card_radius) var a := clampf(0.5 - d, 0.0, 1.0) if a > 0.0: var col := border_color if (d > -1.5) else card_color img.set_pixel(x, y, Color(col.r, col.g, col.b, col.a * a)) _card_images[size] = img return img func _raw_file_icon(base: Control, type: String) -> Texture2D: var icon_name := type if base.has_theme_icon(type, "EditorIcons") else "File" return base.get_theme_icon(icon_name, "EditorIcons") func _get_hd_file_icon(base: Control, type: String, size: int, with_card: bool = true) -> Texture2D: var icon_name := type if base.has_theme_icon(type, "EditorIcons") else "File" if base.has_theme_icon(icon_name + "Big", "EditorIcons"): icon_name += "Big" var cache_key := "file_%s_%d_%s" % [icon_name, size, "card" if with_card else "flat"] if _hd_icon_cache.has(cache_key): return _hd_icon_cache[cache_key] var raw_tex := base.get_theme_icon(icon_name, "EditorIcons") var raw_img := raw_tex.get_image() if raw_img == null: return raw_tex if raw_img.is_compressed(): raw_img.decompress() raw_img.convert(Image.FORMAT_RGBA8) var img: Image var inner_size: int if with_card: # Decorative "card" thumbnail (grid): icon at 52 % for a consistent look across types. img = _get_card_image(size).duplicate() as Image inner_size = int(size * 0.52) else: # No card: the container that displays this texture already has its own frame # (details panel), so the icon fills the thumbnail instead of floating in the middle # of a large transparent area. img = Image.create(size, size, false, Image.FORMAT_RGBA8) inner_size = int(size * 0.82) raw_img.resize(inner_size, inner_size, Image.INTERPOLATE_LANCZOS) var offset := int((size - inner_size) / 2.0) img.blend_rect(raw_img, Rect2i(0, 0, inner_size, inner_size), Vector2i(offset, offset)) var tex := ImageTexture.create_from_image(img) _hd_icon_cache[cache_key] = tex return tex # ---------- Folder colors (inherited, like in the FileSystem dock) ---------- func _colors() -> Dictionary: return ProjectSettings.get_setting("file_customization/folder_colors", {}) func _resolve_color(colors: Dictionary, path: String) -> String: var p := path.trim_suffix("/") var guard := 0 while guard < 64: guard += 1 var v := str(colors.get(p + "/", colors.get(p, ""))) if v != "": return v if p == "res:" or p == "res:/" or p == "" or p == "res://": break var parent := p.get_base_dir() if parent == p: break p = parent return "" func _folder_color(colors: Dictionary, path: String) -> Color: return FOLDER_COLORS.get(_resolve_color(colors, path), DEFAULT_FOLDER_COLOR) func _set_folder_color(path: String, color_key: String) -> void: var colors: Dictionary = _colors().duplicate() var p := path.trim_suffix("/") colors.erase(p) colors.erase(p + "/") if not color_key.is_empty(): colors[p + "/"] = color_key ProjectSettings.set_setting("file_customization/folder_colors", colors) ProjectSettings.save() _tree_dirty = true _refresh() # ---------- Refresh ---------- func _refresh() -> void: if not is_inside_tree() or _list == null: return _sync_favorites() _dirty = false var base := EditorInterface.get_base_control() var prev_selected := _selected_paths() var isz := int(_current_zoom()) _icon_bucket = _bucket(isz) _apply_list_layout() _list.clear() _path_index.clear() _item_colors.clear() _dir_paths.clear() # If the current folder is gone, go up var fs := EditorInterface.get_resource_filesystem() while _active_set.is_empty() and current_dir != "res://" and (fs.get_filesystem_path(current_dir) == null or (not _show_hidden and _is_hidden(current_dir))): current_dir = current_dir.trim_suffix("/").get_base_dir() if current_dir == "res:": current_dir = "res://" _refresh_breadcrumbs() _update_nav_buttons() _refresh_left_tab_content() # entry = [path, type, is_folder, name] var query := _search.text.strip_edges().to_lower() var tokens := query.split(" ", false) var entries: Array = [] if not _active_set.is_empty(): for p in (_sets.get(_active_set, []) as Array): var ps := str(p) if not tokens.is_empty() and not _name_matches(ps.trim_suffix("/").get_file().to_lower(), tokens): continue if DirAccess.dir_exists_absolute(ps): entries.append([ps, "Folder", true, ps.trim_suffix("/").get_file()]) elif FileAccess.file_exists(ps) and _pass_filter(ps, _selected_filter): entries.append([ps, fs.get_file_type(ps), false, ps.get_file()]) elif query.is_empty(): var dir := fs.get_filesystem_path(current_dir) if dir != null: for i in dir.get_subdir_count(): var sub := dir.get_subdir(i) if not _show_hidden and _is_hidden(sub.get_path()): continue entries.append([sub.get_path(), "Folder", true, sub.get_name()]) for i in dir.get_file_count(): var fp := dir.get_file_path(i) if _pass_filter(fp, _selected_filter) and (_show_hidden or not _is_hidden(fp)): entries.append([fp, dir.get_file_type(i), false, dir.get_file(i)]) else: _search_entries(tokens, entries) entries.sort_custom(func(a: Array, b: Array) -> bool: # entry = [path, type, is_folder, name, rank] (rank: 0 exact name, 1 starts with, 2 contains) var ra: int = a[4] if a.size() > 4 else 0 var rb: int = b[4] if b.size() > 4 else 0 if ra != rb: return ra < rb if a[2] != b[2]: return a[2] return str(a[3]).naturalnocasecmp_to(str(b[3])) < 0 ) _truncated = entries.size() > MAX_ITEMS if _truncated: entries.resize(MAX_ITEMS) var custom_colors := _colors() var folder_tex := _get_hd_folder_icon(_icon_bucket) var show_parent := not query.is_empty() and _active_set.is_empty() var check_hidden := _show_hidden and not _hidden.is_empty() for e in entries: var path: String = e[0] var type: String = e[1] var is_dir: bool = e[2] var label: String = e[3] var is_hid := check_hidden and _is_hidden(path) if show_parent: # Results coming from several folders: show where they come from var parent := path.trim_suffix("/").get_base_dir().trim_prefix("res://") if parent.is_empty(): parent = "res://" label += (" · " + parent) if _view_list else ("\n" + parent) var idx: int if is_dir: idx = _list.add_item(label, folder_tex) var fc := _folder_color(custom_colors, path) if is_hid: fc.a *= HIDDEN_ALPHA _list.set_item_icon_modulate(idx, fc) _dir_paths[path] = true else: var tex: Texture2D = _preview_cache.get(path, null) if tex == null: tex = _raw_file_icon(base, type) if (_view_list and _icon_bucket <= 24) else _get_hd_file_icon(base, type, _icon_bucket) idx = _list.add_item(label, tex) if is_hid: _list.set_item_icon_modulate(idx, Color(1, 1, 1, HIDDEN_ALPHA)) _list.set_item_metadata(idx, path) _list.set_item_tooltip(idx, path + (("\n" + L.t("Masqué dans le tiroir")) if is_hid else "")) _path_index[path] = idx _item_colors.append(Color(0, 0, 0, 0) if is_dir else _type_color(path, type)) if _is_fav(path): _list.set_item_custom_fg_color(idx, COLOR_FAV) if is_hid: _list.set_item_custom_fg_color(idx, Color(1, 1, 1, HIDDEN_ALPHA)) var shown := _list.item_count # Restore the selection (or select the newly created item) var reselect := prev_selected if _pending_select != "": reselect = PackedStringArray([_pending_select]) for i in _list.item_count: var mp := str(_list.get_item_metadata(i)) if reselect.has(mp) or reselect.has(mp.trim_suffix("/")): _list.select(i, false) if _pending_select != "": _list.ensure_current_is_visible() _pending_select = "" _empty_state.visible = (shown == 0) _empty_reset.visible = false if shown == 0: if _selected_filter != 0: _empty_lbl.text = L.t("Aucun résultat avec ce filtre") _empty_link.visible = false _empty_reset.visible = true elif not _active_set.is_empty(): _empty_lbl.text = L.t("Set vide") _empty_link.visible = false elif not query.is_empty(): _empty_lbl.text = L.t("Aucun résultat") _empty_link.visible = false else: _empty_lbl.text = L.t("Aucun asset") _empty_link.visible = true _on_selection_changed(false) _schedule_previews() ## Strip color of an asset according to its type (resource class, otherwise extension). func _type_color(path: String, type: String) -> Color: var ext := path.get_extension().to_lower() var known := type != "" and ClassDB.class_exists(type) var kind := "resource" if ext in ["obj", "fbx", "glb", "gltf", "blend", "dae"] or (known and ClassDB.is_parent_class(type, "Mesh")): kind = "model" elif ext in ["tscn", "scn"] or type == "PackedScene": kind = "scene" elif ext in ["gd", "cs", "cpp", "h"] or (known and ClassDB.is_parent_class(type, "Script")): kind = "script" elif ext in ["png", "jpg", "jpeg", "svg", "webp", "bmp", "tga", "exr", "hdr", "dds", "ktx"] or (known and ClassDB.is_parent_class(type, "Texture")): kind = "image" elif ext in ["wav", "ogg", "mp3"] or (known and ClassDB.is_parent_class(type, "AudioStream")): kind = "audio" elif ext in ["gdshader", "shader", "gdshaderinc"] or (known and ClassDB.is_parent_class(type, "Shader")): kind = "shader" elif known and ClassDB.is_parent_class(type, "Material"): kind = "material" elif type.begins_with("Animation") or (known and ClassDB.is_parent_class(type, "Animation")): kind = "animation" elif ext in ["ttf", "otf", "woff", "woff2", "fnt"] or (known and ClassDB.is_parent_class(type, "Font")): kind = "font" return TYPE_COLORS[kind] # ItemList.get_item_rect() does not account for scrolling in every Godot version # (rectangle in content coordinates instead of screen coordinates): the type strips, the # badges and the "Copy" button then stayed fixed while the list was scrolled. # We detect it once with a hover test (which is always correct), then correct for it. var _rect_includes_scroll := -1 # -1 unknown, 0: rect without scroll, 1: rect already on screen func _item_rect(i: int, expand: bool = true) -> Rect2: var r := _list.get_item_rect(i, expand) var sv := _list.get_v_scroll_bar().value if sv <= 0.0: return r if _rect_includes_scroll < 0: _detect_rect_scroll(sv) if _rect_includes_scroll == 0: r.position.y -= sv return r func _detect_rect_scroll(sv: float) -> void: var xs: Array[float] = [24.0, maxf(float(_list.fixed_column_width) * 0.5, 24.0), 80.0] var y := 6.0 while y < _list.size.y: for x in xs: var p := Vector2(x, y) var idx := _list.get_item_at_position(p, true) if idx < 0: continue var r := _list.get_item_rect(idx, true) var as_screen := r.has_point(p) r.position.y -= sv var as_content := r.has_point(p) if as_screen != as_content: # unambiguous result _rect_includes_scroll = 1 if as_screen else 0 return y += 10.0 ## Draws, on top of the list, the colored indicator of each visible item: ## grid = bar inset at the bottom of the thumbnail; list = vertical bar on the left. func _draw_type_bars() -> void: if not SHOW_TYPE_BAR or _list == null: return var n := mini(_list.item_count, _item_colors.size()) if n == 0: return if _bar_style == null: _bar_style = StyleBoxFlat.new() _bar_style.set_corner_radius_all(2) # Thin dark outline: the bar stays readable even on a thumbnail of a color # close to its own (without it, a blue thumbnail with a blue "scene" bar # would almost completely blend into it). _bar_style.border_color = Color(0, 0, 0, 0.55) _bar_style.set_border_width_all(1) var view_h := _list.size.y var icon_w := float(_list.fixed_icon_size.x) var margin := float(_list.get_theme_constant("icon_margin")) var thick := clampf(margin - 1.0, 3.0, 5.0) for i in n: var c := _item_colors[i] if c.a <= 0.0: continue var r := _item_rect(i, true) if r.end.y < 0.0: continue if r.position.y > view_h: break # items are laid out top to bottom: the rest is off screen _bar_style.bg_color = Color(c.r, c.g, c.b, 0.95) if _view_list: _list.draw_style_box(_bar_style, Rect2(r.position.x + 1.0, r.position.y + 3.0, 3.0, maxf(r.size.y - 6.0, 4.0))) else: var x := r.position.x + floorf((r.size.x - icon_w) * 0.5) # Inset in the last pixels of the thumbnail (not in the margin before the # text): so it can never overlap the file name, even on 2 lines. var y := r.position.y + margin + icon_w - thick - 1.0 _list.draw_style_box(_bar_style, Rect2(x, y, icon_w, thick)) ## On top of the list: ✓ badges of selected items, drop target (highlighted folder with ## "Move here" pill, or frame around the list) and the selection rectangle. func _draw_list_overlay() -> void: if _list == null: return var acc: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var view_h := _list.size.y if not _view_list: for i in _list.get_selected_items(): var r := _item_rect(i, true) if r.end.y < 0.0: continue if r.position.y > view_h: break var c := Vector2(r.end.x - 11.0, r.position.y + 11.0) _list.draw_circle(c, 7.5, acc) _list.draw_polyline(PackedVector2Array([c + Vector2(-3.2, 0.3), c + Vector2(-0.8, 2.7), c + Vector2(3.4, -2.3)]), Color.WHITE if acc.get_luminance() < 0.6 else Color.BLACK, 1.6, true) if _drop_whole: _list.draw_style_box(_make_stylebox(Color(acc.r, acc.g, acc.b, 0.06), Color(acc.r, acc.g, acc.b, 0.75), 8, 2), Rect2(Vector2.ZERO, _list.size)) if _drop_list_idx >= 0 and _drop_list_idx < _list.item_count: var r2 := _item_rect(_drop_list_idx, true) var glow := _make_stylebox(Color(acc.r, acc.g, acc.b, 0.26), acc, 9, 2) glow.shadow_color = Color(acc.r, acc.g, acc.b, 0.5) glow.shadow_size = 8 _list.draw_style_box(glow, r2) var font := _list.get_theme_font("font") var fs := 10 var txt := L.t("Déplacer ici") var tw := font.get_string_size(txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x var pr := Rect2(r2.position.x + (r2.size.x - (tw + 14.0)) * 0.5, r2.position.y + 3.0, tw + 14.0, 16.0) _list.draw_style_box(_make_stylebox(acc, Color.TRANSPARENT, 8), pr) _list.draw_string(font, Vector2(pr.position.x + 7.0, pr.position.y + 11.5), txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color.WHITE if acc.get_luminance() < 0.6 else Color.BLACK) if _mq_active and _mq_moved: var sv := Vector2(_mq_start.x, _mq_start.y - _list.get_v_scroll_bar().value) var mr := Rect2(sv, _mq_cur - sv).abs() _list.draw_style_box(_make_stylebox(Color(acc.r, acc.g, acc.b, 0.16), Color(acc.r, acc.g, acc.b, 0.9), 3, 1), mr) ## Tree folder targeted during a drag and drop: rounded accent frame. func _draw_tree_drop() -> void: if _drop_tree_item == null or not is_instance_valid(_drop_tree_item): return var acc: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var r := _tree.get_item_area_rect(_drop_tree_item, 0).grow(-1.0) var st := _make_stylebox(Color(acc.r, acc.g, acc.b, 0.25), acc, 6, 2) st.shadow_color = Color(acc.r, acc.g, acc.b, 0.4) st.shadow_size = 5 _tree.draw_style_box(st, r) func _current_zoom() -> float: return _zoom_list if _view_list else _zoom_grid # Adapts the slider range to the current mode and restores the remembered zoom of THIS mode func _configure_zoom() -> void: if _zoom == null: return _zoom.set_block_signals(true) if _view_list: _zoom.min_value = 16 _zoom.max_value = 48 _zoom.step = 2 _zoom.value = clampf(_zoom_list, 16.0, 48.0) _zoom.tooltip_text = L.t("Taille des icônes en liste (Ctrl+molette)") else: _zoom.min_value = 48 _zoom.max_value = 160 _zoom.step = 8 _zoom.value = clampf(_zoom_grid, 48.0, 160.0) _zoom.tooltip_text = L.t("Taille des miniatures en grille (Ctrl+molette)") _zoom.set_block_signals(false) func _apply_list_layout() -> void: var s := int(_current_zoom()) if _view_list: _list.icon_mode = ItemList.ICON_MODE_LEFT _list.max_columns = 1 _list.max_text_lines = 1 _list.fixed_icon_size = Vector2i(s, s) _list.fixed_column_width = 0 else: _list.icon_mode = ItemList.ICON_MODE_TOP _list.max_columns = 0 _list.max_text_lines = 2 _list.fixed_icon_size = Vector2i(s, s) _list.fixed_column_width = s + 28 ## Called when the user releases the separator between the list and the details panel. ## `offset` is already clamped by the SplitContainer (respects the minimums of both sides). func _on_details_split_dragged(offset: int) -> void: _details_width = maxf(DETAILS_MIN_WIDTH - float(offset), DETAILS_MIN_WIDTH) _save_cfg() func _on_zoom_changed(v: float) -> void: if _view_list: _zoom_list = v else: _zoom_grid = v _save_cfg() if not is_open: _dirty = true return # Zoom only affects the layout; the list is rebuilt only if the # generated icon size changes tier. _apply_list_layout() if _bucket(int(v)) != _icon_bucket: _refresh() else: _schedule_previews() # Zoom slider readable on a dark background: light track, filled part in accent, round handle func _style_zoom_slider(accent: Color) -> void: var track := StyleBoxFlat.new() track.bg_color = COLOR_TRACK track.set_corner_radius_all(2) track.content_margin_top = 2 track.content_margin_bottom = 2 var fill := track.duplicate() as StyleBoxFlat fill.bg_color = accent var fill_hi := track.duplicate() as StyleBoxFlat fill_hi.bg_color = accent.lightened(0.2) _zoom.add_theme_stylebox_override("slider", track) _zoom.add_theme_stylebox_override("grabber_area", fill) _zoom.add_theme_stylebox_override("grabber_area_highlight", fill_hi) _zoom.add_theme_icon_override("grabber", _make_dot(12, TEXT_PRIMARY)) _zoom.add_theme_icon_override("grabber_highlight", _make_dot(12, Color.WHITE)) _zoom.add_theme_icon_override("grabber_disabled", _make_dot(12, COLOR_TRACK)) func _make_dot(d: int, col: Color) -> ImageTexture: var img := Image.create(d, d, false, Image.FORMAT_RGBA8) var c := (d - 1) * 0.5 var r := d * 0.5 - 0.5 for y in d: for x in d: var dist := Vector2(x - c, y - c).length() img.set_pixel(x, y, Color(col.r, col.g, col.b, clampf(r + 0.5 - dist, 0.0, 1.0))) return ImageTexture.create_from_image(img) # ---------- Lazy thumbnails ---------- func _schedule_previews() -> void: if _preview_timer != null and is_open: _preview_timer.start() func _request_visible_previews() -> void: # Only requests thumbnails of visible items (+ one page of margin on each side) if not is_open or _list == null or _list.item_count == 0: return if _list.has_method("force_update_list_size"): _list.call("force_update_list_size") var first := _list.get_item_at_position(Vector2(6, 6), false) var last := _list.get_item_at_position(Vector2(_list.size.x - 6, _list.size.y - 6), false) if first < 0 or last < first: first = 0 last = mini(_list.item_count - 1, 59) var span := maxi(last - first + 1, 24) var lo := maxi(first - span, 0) var hi := mini(last + span, _list.item_count - 1) var previewer := EditorInterface.get_resource_previewer() for i in range(lo, hi + 1): var path := str(_list.get_item_metadata(i)) if _dir_paths.has(path) or _preview_pending.has(path): continue if path.get_extension().to_lower() == "svg" and not _svg_fail.has(path): # Godot's thumbnail is small (≈ 64 px) then enlarged: blurry. We rasterize the # SVG at display size, in small batches so the interface doesn't stutter. var target := _svg_target() if _preview_cache.has(path) and int(_svg_px.get(path, 0)) >= target: continue _preview_pending[path] = true _svg_queue.append([path, target]) continue if _preview_cache.has(path): continue _preview_pending[path] = true previewer.queue_resource_preview(path, self, "_on_preview", path) ## All the search words must appear in the name (already lowercase). func _name_matches(name_lc: String, tokens: PackedStringArray) -> bool: for t in tokens: if not name_lc.contains(t): return false return true ## Flat index (path, type, folder?, name, lowercase name) of the whole project. ## Rebuilt only when the file system changes: a search no longer walks ## the editor's tree, just this array. func _build_index() -> void: _index.clear() var fs := EditorInterface.get_resource_filesystem().get_filesystem() if fs != null: _index_dir(fs) _index_dirty = false func _index_dir(dir: EditorFileSystemDirectory) -> void: for i in dir.get_subdir_count(): var sub := dir.get_subdir(i) var sname := sub.get_name() _index.append([sub.get_path(), "Folder", true, sname, sname.to_lower()]) _index_dir(sub) for i in dir.get_file_count(): var fname := dir.get_file(i) _index.append([dir.get_file_path(i), dir.get_file_type(i), false, fname, fname.to_lower()]) ## Search across the whole project (or under the current folder). Results ranked: ## exact name, then name starting with the 1st word, then name containing it. func _search_entries(tokens: PackedStringArray, out: Array) -> void: if tokens.is_empty(): return if _index_dirty: _build_index() var prefix := "" if not _search_all: prefix = current_dir if current_dir.ends_with("/") else current_dir + "/" var check_hidden := not _show_hidden and not _hidden.is_empty() var cap := MAX_ITEMS * 4 var first: String = tokens[0] for e in _index: var p: String = e[0] if prefix != "" and (p == prefix or not p.begins_with(prefix)): continue var nm: String = e[4] if not _name_matches(nm, tokens): continue if not e[2] and not _pass_filter(p, _selected_filter): continue if check_hidden and _is_hidden(p): continue var rank := 2 if nm.get_basename() == first or nm == first: rank = 0 elif nm.begins_with(first): rank = 1 out.append([p, e[1], e[2], e[3], rank]) if out.size() > cap: break func _on_scope_toggled(on: bool) -> void: _search_all = on _update_search_ui() _save_cfg() if not _search.text.strip_edges().is_empty(): _refresh() func _update_search_ui() -> void: if _search != null: _search.placeholder_text = L.t("Rechercher dans tout le projet... (Ctrl+F)") if _search_all else L.t("Rechercher dans ce dossier... (Ctrl+F)") if _btn_scope != null: _btn_scope.tooltip_text = L.t("Recherche : tout le projet (cliquer pour limiter au dossier courant)") if _search_all else L.t("Recherche : dossier courant (cliquer pour chercher dans tout le projet)") # ---------- Hidden items ---------- ## Stored key: a folder ends with "/", a file keeps its exact path. func _hidden_key(path: String) -> String: if DirAccess.dir_exists_absolute(path): return path.trim_suffix("/") + "/" return path func _is_hidden(path: String) -> bool: if _hidden.is_empty(): return false var pd := path if path.ends_with("/") else path + "/" for h in _hidden: if h.ends_with("/"): if pd.begins_with(h): return true elif path == h: return true return false func _hide_paths(paths: PackedStringArray, hide: bool) -> void: for p in paths: if p == "res://": continue var key := _hidden_key(p) if hide: if not _hidden.has(key): _hidden.append(key) else: var i := _hidden.find(key) if i >= 0: _hidden.remove_at(i) _tree_dirty = true _save_cfg() _list.deselect_all() _refresh() if hide and not _show_hidden: _flash(L.t("Masqué dans le tiroir · clic droit → « Afficher les éléments masqués »")) else: _flash(L.t("Masqué dans le tiroir") if hide else L.t("Réaffiché")) func _set_show_hidden(on: bool) -> void: _show_hidden = on _tree_dirty = true _save_cfg() _refresh() func _pass_filter(path: String, filter_idx: int) -> bool: if filter_idx <= 0: return true var ext := path.get_extension().to_lower() match filter_idx: 1: return ext in ["tscn", "scn"] 2: return ext in ["gd", "cs", "cpp", "h"] 3: return ext in ["obj", "fbx", "glb", "gltf", "blend"] 4: return ext in ["png", "jpg", "jpeg", "svg", "webp", "bmp", "tga", "exr", "hdr"] 5: return ext in ["wav", "ogg", "mp3"] 6: return ext in ["gdshader", "shader", "gdshaderinc"] return true func _selected_paths() -> PackedStringArray: var out: PackedStringArray = [] if _list == null: return out for i in _list.get_selected_items(): out.append(str(_list.get_item_metadata(i))) return out func _on_selection_changed(select_in_dock: bool = true) -> void: var sel := _selected_paths() if sel.size() == 0: _update_details(current_dir if _active_set.is_empty() else "res://") elif sel.size() == 1: if select_in_dock: EditorInterface.select_file(sel[0]) _update_details(sel[0]) else: _context_path = sel[0] _details_name.text = L.t("%d éléments sélectionnés") % sel.size() _details_path.text = "" _lbl_type.text = "-" _lbl_size.text = "-" _lbl_modified.text = "-" _lbl_uid.text = "-" _set_preview_tex(null) _preview.self_modulate = Color.WHITE _details_path.visible = false _sync_details_meta() _update_details_layout.call_deferred() _update_status_bar() func _update_status_bar(_unused: int = -1) -> void: var total := _list.item_count var sel_count := _list.get_selected_items().size() var where := current_dir if _active_set.is_empty() else L.t("Set : ") + _active_set var txt := L.t("%d élément%s") % [total, "s" if total > 1 else ""] if _truncated: txt += L.t(" (limité à %d)") % MAX_ITEMS if sel_count > 0: txt += L.t(" · %d sélectionné%s") % [sel_count, L.sel_s(sel_count > 1)] _status.text = txt + " · " + where func _flash(msg: String, is_error: bool = false) -> void: _status.text = msg get_tree().create_timer(2.5).timeout.connect(_update_status_bar) _show_toast(msg, is_error) func _show_toast(msg: String, is_error: bool) -> void: if _toast == null or not is_instance_valid(_toast): return _toast_lbl.text = msg var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var col: Color = COLOR_DANGER if is_error else accent _toast.add_theme_stylebox_override("panel", _make_stylebox(COLOR_POPUP_BG, Color(col.r, col.g, col.b, 0.85), 8, 1)) if _toast_tween and _toast_tween.is_valid(): _toast_tween.kill() _toast.pivot_offset = _toast.size * 0.5 _toast.scale = Vector2(0.88, 0.88) _toast.modulate.a = 0.0 _toast_tween = create_tween() _toast_tween.tween_property(_toast, "modulate:a", 1.0, 0.16).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) _toast_tween.parallel().tween_property(_toast, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _toast_tween.tween_interval(1.7) _toast_tween.tween_property(_toast, "modulate:a", 0.0, 0.3).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) _toast_tween.parallel().tween_property(_toast, "scale", Vector2(0.94, 0.94), 0.3).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) func _rel_time(mtime: int) -> String: var diff := int(Time.get_unix_time_from_system()) - mtime if diff < 60: return L.t("à l'instant") if diff < 3600: return L.t("il y a %d min") % (diff / 60) if diff < 86400: return L.t("il y a %d h") % (diff / 3600) if diff < 172800: return L.t("hier") var d := Time.get_date_dict_from_unix_time(mtime) if L.is_fr(): return "%02d/%02d/%d" % [d.day, d.month, d.year] return "%d-%02d-%02d" % [d.year, d.month, d.day] func _update_details(path: String) -> void: _context_path = path var is_dir := DirAccess.dir_exists_absolute(path) _details_name.text = _soft_wrap("res://" if path == "res://" else path.trim_suffix("/").get_file()) _details_path.text = _soft_wrap(path) _details_path.visible = path != "res://" if is_dir: _lbl_type.text = L.t("Dossier") var n := DirAccess.get_directories_at(path).size() + DirAccess.get_files_at(path).size() _lbl_size.text = L.t("%d élément%s") % [n, "s" if n > 1 else ""] _lbl_modified.text = "-" _lbl_uid.text = "-" _set_preview_tex(_get_hd_folder_icon(96)) _preview.self_modulate = _folder_color(_colors(), path) else: var t := EditorInterface.get_resource_filesystem().get_file_type(path) _preview.self_modulate = Color.WHITE _lbl_type.text = t if t != "" else path.get_extension().to_upper() _lbl_size.text = "-" _lbl_modified.text = "-" _lbl_uid.text = "-" if FileAccess.file_exists(path): var f := FileAccess.open(path, FileAccess.READ) if f: var bytes := f.get_length() f.close() if bytes < 1024: _lbl_size.text = L.t("%d o") % bytes elif bytes < 1024 * 1024: _lbl_size.text = L.t("%.1f Ko") % (bytes / 1024.0) else: _lbl_size.text = L.t("%.1f Mo") % (bytes / (1024.0 * 1024.0)) var mtime := FileAccess.get_modified_time(path) if mtime > 0: _lbl_modified.text = _rel_time(mtime) if ResourceLoader.has_method("get_resource_uid"): var uid: int = ResourceLoader.call("get_resource_uid", path) if uid != -1: _lbl_uid.text = ResourceUID.id_to_text(uid) var cached = _preview_cache.get(path, null) if cached != null: _set_preview_tex(cached) else: _set_preview_tex(_get_hd_file_icon(EditorInterface.get_base_control(), t, 96, false)) if not _preview_pending.has(path): _preview_pending[path] = true EditorInterface.get_resource_previewer().queue_resource_preview(path, self, "_on_preview", path) _set_fav_state(path) if is_instance_valid(_btn_copy_res): _btn_copy_res.visible = not is_dir _sync_details_meta() _update_details_layout.call_deferred() func _refresh_breadcrumbs() -> void: for c in _breadcrumbs.get_children(): _breadcrumbs.remove_child(c) c.queue_free() var bold := EditorInterface.get_base_control().get_theme_font("bold", "EditorFonts") var parts: Array = [] # [text, path or ""] if not _active_set.is_empty(): parts.append(["Sets", ""]) parts.append([_active_set, ""]) else: var segs := current_dir.split("/", false) var build := "" for i in range(segs.size()): if i == 0 and segs[i] == "res:": build = "res://" parts.append(["res://", build]) else: build += segs[i] + "/" parts.append([segs[i], build]) for i in range(parts.size()): var btn := Button.new() btn.text = parts[i][0] _style_ghost_button(btn, 6, 2) btn.add_theme_font_size_override("font_size", 12) btn.focus_mode = Control.FOCUS_NONE btn.add_theme_font_override("font", bold) var target: String = parts[i][1] if target != "": btn.pressed.connect(func() -> void: _navigate(target)) else: btn.mouse_filter = Control.MOUSE_FILTER_IGNORE # non-clickable segment: no hover _breadcrumbs.add_child(btn) if i < parts.size() - 1: var sep := Label.new() sep.text = "›" sep.modulate = TEXT_MUTED _breadcrumbs.add_child(sep) func _refresh_tree() -> void: _tree_dirty = false _syncing_tree = true _tree.clear() _tree_items.clear() _sticky_sig = "" _row_pitch = 0.0 _pending_reveal = null var fs := EditorInterface.get_resource_filesystem().get_filesystem() if fs == null: _syncing_tree = false return var icon := EditorInterface.get_base_control().get_theme_icon("Folder", "EditorIcons") var root := _tree.create_item() root.set_text(0, "res://") root.set_metadata(0, "res://") root.set_icon(0, icon) _tree_items["res:/"] = root _fill_tree(root, fs, _colors(), icon) _syncing_tree = false _sync_tree_selection() func _sync_tree_selection() -> void: # Selects the current folder without rebuilding the tree if _tree == null or not _active_set.is_empty(): return var target: TreeItem = _tree_items.get(current_dir.trim_suffix("/"), null) if target == null: return _syncing_tree = true var p := target.get_parent() while p != null: p.collapsed = false p = p.get_parent() target.select(0) _syncing_tree = false _reveal_tree_item.call_deferred(target) func _fill_tree(parent: TreeItem, dir: EditorFileSystemDirectory, colors: Dictionary, icon: Texture2D) -> void: for i in dir.get_subdir_count(): var sub := dir.get_subdir(i) var is_hid := _is_hidden(sub.get_path()) if is_hid and not _show_hidden: continue var item := _tree.create_item(parent) item.set_text(0, sub.get_name()) item.set_metadata(0, sub.get_path()) item.set_icon(0, icon) var tint := _folder_color(colors, sub.get_path()) if is_hid: tint.a *= HIDDEN_ALPHA item.set_custom_color(0, Color(1, 1, 1, HIDDEN_ALPHA)) item.set_icon_modulate(0, tint) _tree_items[sub.get_path().trim_suffix("/")] = item _fill_tree(item, sub, colors, icon) # Collapsed by default (large projects); the current folder is expanded on selection item.collapsed = bool(_collapsed.get(sub.get_path(), true)) func _find_tree_item(item: TreeItem, path: String) -> TreeItem: var want := path.trim_suffix("/") if str(item.get_metadata(0)).trim_suffix("/") == want: return item for c in item.get_children(): var r := _find_tree_item(c, path) if r: return r return null func _on_tree_selected() -> void: if _syncing_tree: return var item := _tree.get_selected() if item: var p := str(item.get_metadata(0)) if _active_set.is_empty() and p.trim_suffix("/") == current_dir.trim_suffix("/"): return _navigate.call_deferred(p) # ---------- Tree: pinned (sticky) items, single click, tools ---------- func _add_tree_tool_button(icon_name: String, fallback: String, tip: String, cb: Callable) -> void: var b := Button.new() b.flat = true b.focus_mode = Control.FOCUS_NONE b.tooltip_text = tip var base := EditorInterface.get_base_control() if base.has_theme_icon(icon_name, "EditorIcons"): b.icon = base.get_theme_icon(icon_name, "EditorIcons") else: b.text = fallback b.pressed.connect(cb) _tree_tools.add_child(b) func _setup_sticky() -> void: # Overlay placed on the tree: shows the parent folders "stuck" at the top. # Home-made implementation (works from Godot 4.2, without relying on 4.8's sticky Tree). _sticky_box = Control.new() _sticky_box.mouse_filter = Control.MOUSE_FILTER_IGNORE # Safety net: whatever happens (late signature, shaky row measurement on the # first display, etc.), the overlay must NEVER be able to draw outside the tree's # rectangle — neither above nor below. _sticky_box.clip_contents = true _sticky_box.visible = false _tree.clip_contents = true _tree.add_child(_sticky_box) for c in _tree.get_children(true): if c is VScrollBar: _tree_vscroll = c break func _process(_delta: float) -> void: if _tree != null and _tree.is_visible_in_tree(): _update_sticky() _update_paste_pop_hover() _update_copy_pop_hover() _pump_svg_queue() # Safeguard: the "Ctrl+Space" pill must always be invisible while the drawer # is open. Reasserted every frame rather than relying only on open()/close(), # in case a side path (theme reload, deferred call...) missed it. if SHOW_HINT and is_instance_valid(_hint) and _hint.visible == is_open: _hint.visible = not is_open # Height of a tree row, measured by finding two consecutive row changes. func _get_row_pitch() -> float: if _row_pitch > 0.0: return _row_pitch var last: TreeItem = _tree.get_item_at_position(Vector2(4.0, 0.0)) if last == null: return 0.0 var edges: Array[int] = [] for y in range(1, 200): var it: TreeItem = _tree.get_item_at_position(Vector2(4.0, float(y))) if it == null: break if it != last: edges.append(y) last = it if edges.size() == 2: break if edges.size() == 2 and edges[1] - edges[0] >= 8: _row_pitch = float(edges[1] - edges[0]) return _row_pitch func _item_depth(item: TreeItem) -> int: var d := 0 var p := item.get_parent() while p != null: d += 1 p = p.get_parent() return d func _row_index(target: TreeItem) -> int: var idx := 0 var it: TreeItem = _tree.get_root() while it != null: if it == target: return idx idx += 1 it = it.get_next_visible() return -1 # List (root -> deep) of the folders to pin at the top of the tree. func _compute_sticky_chain() -> Array: var chain: Array = [] if _tree.get_root() == null or _tree.get_scroll().y < 1.0: return chain var pitch := _get_row_pitch() if pitch <= 0.0: return chain var slot := _sticky_row_h(pitch) # = pitch: we probe at multiples of a WHOLE row var k := 0 for _i in range(STICKY_MAX + 1): # Row located just below the rows already pinned var probe: TreeItem = _tree.get_item_at_position(Vector2(4.0, k * slot + 1.0)) if probe == null: break var cand: Array = [] var p := probe.get_parent() while p != null: cand.push_front(p) p = p.get_parent() if cand.size() > STICKY_MAX: cand = cand.slice(cand.size() - STICKY_MAX) chain = cand if cand.size() == k: break k = cand.size() return chain func _update_sticky() -> void: if _sticky_box == null: return var pitch := _get_row_pitch() if _pending_reveal != null: var pr := _pending_reveal if pitch > 0.0: _pending_reveal = null if is_instance_valid(pr): _reveal_tree_item(pr) # Immediate, priority hiding: as soon as the tree is at the very top, there is no parent folder # to show. We never depend on the signature cache for this specific case, to avoid # a leftover of the overlay staying visible for a moment above "res://". (The handling of # _pending_reveal above must stay BEFORE this test: it is what can move the # scroll and therefore change this result on the next call.) if _tree.get_scroll().y < 1.0: if _sticky_box.visible: _sticky_box.visible = false _sticky_sig = "" return var chain := _compute_sticky_chain() var w := _tree.size.x if _tree_vscroll != null and _tree_vscroll.visible: w -= _tree_vscroll.size.x var sig := "%d#%s#%d" % [int(w), current_dir, int(pitch)] for it in chain: sig += "|" + str(it.get_metadata(0)) if sig == _sticky_sig: return _sticky_sig = sig _rebuild_sticky(chain, w, pitch) ## RESERVED height per pinned row: always one whole tree row. ## Never reduce this value: it is what guarantees that the pinned-folders overlay ## covers exactly a whole number of real rows, ## otherwise a real row appears half under the overlay (overlap). func _sticky_row_h(pitch: float) -> float: return pitch func _rebuild_sticky(chain: Array, w: float, pitch: float) -> void: for c in _sticky_box.get_children(): _sticky_box.remove_child(c) c.queue_free() if chain.is_empty() or pitch <= 0.0: _sticky_box.visible = false return var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") var margin := maxi(_tree.get_theme_constant("item_margin"), 8) var slot := _sticky_row_h(pitch) # = pitch: one reserved row = one whole tree row # Explicit placement, one WHOLE row after the other: the overlay then covers exactly # the same number of pixels as the real rows it hides, no row can # end up half visible under the overlay (overlap). var y := 0.0 for it in chain: var r := _make_sticky_row(it, pitch, margin, accent) _sticky_box.add_child(r) r.position = Vector2(0.0, y) r.size = Vector2(w, slot) y += slot var line := ColorRect.new() line.color = Color(accent.r, accent.g, accent.b, 0.55) line.mouse_filter = Control.MOUSE_FILTER_IGNORE _sticky_box.add_child(line) line.position = Vector2(0.0, y) line.size = Vector2(w, 1.0) _sticky_box.position = Vector2.ZERO _sticky_box.size = Vector2(w, y + 1.0) _sticky_box.visible = true func _sticky_row_style(bg: Color) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = bg s.border_width_bottom = 1 s.border_color = Color(0, 0, 0, 0.35) return s func _make_sticky_row(it: TreeItem, pitch: float, margin: int, accent: Color) -> Control: var path := str(it.get_metadata(0)) var depth := _item_depth(it) var slot := _sticky_row_h(pitch) # RESERVED height = one whole row (alignment) var is_cur := path.trim_suffix("/") == current_dir.trim_suffix("/") var bg := _hover_c(COLOR_LEFT_PANEL) if is_cur: bg = bg.lerp(accent, 0.30) bg.a = 1.0 # always opaque: this row must fully hide the real tree behind it var hover_bg := _hover_c(bg) hover_bg.a = 1.0 var sb_normal := _sticky_row_style(bg) var sb_hover := _sticky_row_style(hover_bg) # PanelContainer: its "panel" style paints the whole rectangle ALONE (the full # reserved height, without the slightest transparent gap), and it automatically centers its # content — no more manual anchoring math, so no possible offset. var row := PanelContainer.new() row.custom_minimum_size = Vector2(0, slot) row.clip_contents = true row.mouse_filter = Control.MOUSE_FILTER_PASS # the mouse wheel keeps going to the tree row.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND row.tooltip_text = path row.add_theme_stylebox_override("panel", sb_normal) row.mouse_entered.connect(func() -> void: row.add_theme_stylebox_override("panel", sb_hover)) row.mouse_exited.connect(func() -> void: row.add_theme_stylebox_override("panel", sb_normal)) # Inner margins: shift the content without ever letting the background show through # (they stay INSIDE the opaque panel above). var pad := MarginContainer.new() pad.add_theme_constant_override("margin_left", depth * margin + 4) pad.add_theme_constant_override("margin_right", 6) pad.add_theme_constant_override("margin_top", 0) pad.add_theme_constant_override("margin_bottom", 0) pad.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(pad) var hb := HBoxContainer.new() hb.add_theme_constant_override("separation", 4) hb.mouse_filter = Control.MOUSE_FILTER_IGNORE pad.add_child(hb) var arrow := TextureRect.new() arrow.custom_minimum_size = Vector2(margin, 0) arrow.expand_mode = TextureRect.EXPAND_KEEP_SIZE arrow.stretch_mode = TextureRect.STRETCH_KEEP_CENTERED arrow.size_flags_vertical = Control.SIZE_SHRINK_CENTER arrow.mouse_filter = Control.MOUSE_FILTER_IGNORE if _tree.has_theme_icon("arrow", "Tree"): arrow.texture = _tree.get_theme_icon("arrow", "Tree") hb.add_child(arrow) var icon := TextureRect.new() icon.texture = it.get_icon(0) icon.modulate = it.get_icon_modulate(0) var isz := clampf(slot - 6.0, 10.0, 20.0) icon.custom_minimum_size = Vector2(isz, isz) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon.mouse_filter = Control.MOUSE_FILTER_IGNORE hb.add_child(icon) var lbl := Label.new() lbl.text = it.get_text(0) lbl.clip_text = true lbl.add_theme_font_size_override("font_size", 12) lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL # EXPLICIT centering: the Label receives the whole row height (SIZE_FILL) and centers # its text inside it (vertical_alignment), rather than depending on the "minimum size" # computed by Godot (unreliable depending on font/DPI). lbl.size_flags_vertical = Control.SIZE_FILL lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE hb.add_child(lbl) var arrow_end := float(depth * margin + margin + 2) row.gui_input.connect(func(ev: InputEvent) -> void: if ev is InputEventMouseButton: var mb := ev as InputEventMouseButton if mb.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN, MOUSE_BUTTON_WHEEL_LEFT, MOUSE_BUTTON_WHEEL_RIGHT]: return row.accept_event() if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: if mb.position.x < arrow_end: _on_sticky_arrow(path) else: _on_sticky_clicked(path) elif ev is InputEventMouseMotion: row.accept_event() ) return row func _sticky_item(path: String) -> TreeItem: var v: Variant = _tree_items.get(path.trim_suffix("/"), null) if v != null and is_instance_valid(v): return v as TreeItem return null # Click on a pinned folder: opens the folder and brings it just under its pinned parents func _on_sticky_clicked(path: String) -> void: if path.trim_suffix("/") != current_dir.trim_suffix("/"): _navigate.call_deferred(path) var it := _sticky_item(path) if it != null: _reveal_tree_item.call_deferred(it) # Click on the arrow of a pinned folder: collapses it func _on_sticky_arrow(path: String) -> void: var it := _sticky_item(path) if it == null: return it.collapsed = true _reveal_tree_item.call_deferred(it) # Makes the item visible, taking into account the pinned rows that would cover it func _reveal_tree_item(item: TreeItem) -> void: if item == null or not is_instance_valid(item) or _tree == null: return _tree.scroll_to_item(item) if _tree_vscroll == null: return var pitch := _get_row_pitch() if pitch <= 0.0: _pending_reveal = item # tree not measurable yet: we try again later return var idx := _row_index(item) if idx < 0: return var need := mini(_item_depth(item), STICKY_MAX) * _sticky_row_h(pitch) var y_top := idx * pitch - _tree.get_scroll().y if y_top < need - 0.5: _tree_vscroll.value = maxf(0.0, _tree_vscroll.value - (need - y_top)) # Single click on a tree folder: navigation (through the selection) + expansion func _on_tree_gui_input(ev: InputEvent) -> void: if not (ev is InputEventMouseButton): return var mb := ev as InputEventMouseButton if mb.button_index != MOUSE_BUTTON_LEFT or not mb.pressed or mb.double_click: return var item := _tree.get_item_at_position(mb.position) if item == null: return var path := str(item.get_metadata(0)) var was_current := path.trim_suffix("/") == current_dir.trim_suffix("/") _after_tree_click.call_deferred(item, item.collapsed, was_current) func _after_tree_click(item: TreeItem, was_collapsed: bool, was_current: bool) -> void: if not is_instance_valid(item) or item.collapsed != was_collapsed: return # click on the arrow: already handled by Godot var path := str(item.get_metadata(0)) if item.get_first_child() != null: if was_current: item.collapsed = not was_collapsed # re-click on the current folder: toggle elif was_collapsed: item.collapsed = false # new folder: we expand it _center_tree_item_soon(path) # Waits for the tree to finish its layout (expansion, selection) then centers the clicked folder func _center_tree_item_soon(path: String) -> void: await get_tree().process_frame await get_tree().process_frame _center_tree_item(_sticky_item(path)) # Scrolls the tree (smoothly) so that the item is in the middle of the visible area func _center_tree_item(item: TreeItem) -> void: if item == null or not is_instance_valid(item) or _tree_vscroll == null: return if not _tree.is_visible_in_tree(): return var pitch := _get_row_pitch() if pitch <= 0.0: return var idx := _row_index(item) if idx < 0: return # Space reserved for the pinned parent folders of THIS item (same rule as the # sticky overlay: always a whole number of rows). If we ignore it here, scrolling can bring # the item just under the overlay with a fraction-of-a-row offset, which makes # a real row appear half visible UNDER the overlay (the overlap bug). var reserved := mini(_item_depth(item), STICKY_MAX) * _sticky_row_h(pitch) var available := maxf(_tree.size.y - reserved, pitch) # Number of context rows to leave visible above the item, in half of the # space available under the overlay — always a WHOLE number of rows to stay aligned. var context_rows := mini(idx, floori((available * 0.5) / pitch)) var target := (idx - context_rows) * pitch - reserved var max_v := maxf(0.0, _tree_vscroll.max_value - _tree_vscroll.page) target = clampf(target, 0.0, max_v) if _center_tween != null and _center_tween.is_valid(): _center_tween.kill() _center_tween = create_tween() _center_tween.tween_property(_tree_vscroll, "value", target, 0.18) \ .set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) func _collapse_all_folders() -> void: var root := _tree.get_root() if root == null: return for c in root.get_children(): _collapse_rec(c) _sync_tree_selection() func _collapse_rec(it: TreeItem) -> void: for c in it.get_children(): _collapse_rec(c) if not it.collapsed: it.collapsed = true func _set_preview_tex(tex: Texture2D) -> void: _preview.texture = tex for sh in _preview_shadows: sh.texture = tex ## Size (px) at which to rasterize SVGs, depending on zoom and editor scale ## (≈ 2× the displayed size: the ItemList then downscales without aliasing). func _svg_target() -> int: var base_px := 128 match _icon_bucket: 24: base_px = 64 64: base_px = 128 96: base_px = 192 160: base_px = 320 var ui_scale := clampf(EditorInterface.get_editor_scale(), 1.0, 2.0) return mini(int(base_px * ui_scale), 512) func _pump_svg_queue() -> void: if _svg_queue.is_empty(): return var t0 := Time.get_ticks_usec() while not _svg_queue.is_empty() and Time.get_ticks_usec() - t0 < 6000: var job: Array = _svg_queue.pop_front() var path: String = job[0] var target: int = job[1] var tex := _render_svg(path, target) if tex != null: _svg_px[path] = target _on_preview(path, tex, null, "svg") else: _svg_fail[path] = true _preview_pending.erase(path) _schedule_previews() # falls back to Godot's thumbnail func _render_svg(path: String, target: int) -> Texture2D: var text := FileAccess.get_file_as_string(path) if text.is_empty(): return null var img := Image.new() if img.load_svg_from_string(text, 1.0) != OK or img.get_width() <= 0 or img.get_height() <= 0: return null var longest := maxi(img.get_width(), img.get_height()) if absf(float(longest) - float(target)) > 2.0: # Direct rendering at the right scale (vector: no loss, unlike an enlargement) var img2 := Image.new() if img2.load_svg_from_string(text, float(target) / float(longest)) == OK and img2.get_width() > 0: img = img2 return ImageTexture.create_from_image(img) func _on_preview(path: String, preview: Texture2D, _thumb: Texture2D, _user) -> void: _preview_pending.erase(path) if str(_user) != "svg" and _svg_px.has(path) and _preview_cache.get(path, null) != null: return # we keep our own rasterization, which is sharper if _preview_cache.size() > 4000: _preview_cache.clear() _preview_cache[path] = preview # null = failure, avoids asking again in a loop if preview == null or _list == null: return if _path_index.has(path): var i: int = _path_index[path] if i < _list.item_count and str(_list.get_item_metadata(i)) == path: _list.set_item_icon(i, preview) if _context_path == path and _list.get_selected_items().size() <= 1: _set_preview_tex(preview) # ---------- Animated opening / closing ---------- func toggle() -> void: if is_open: close() elif Time.get_ticks_msec() - _outside_close_msec > 250: # Avoids reopening right away when the click that just closed the drawer # (outside click) lands on the toolbar button. open() func open() -> void: var base := EditorInterface.get_base_control() _sync_favorites() is_open = true _set_hint_visible(false) _layout(base) position.y = base.size.y + 20.0 modulate.a = 0.0 scale = Vector2(0.965, 0.935) pivot_offset = Vector2(size.x * 0.5, size.y) visible = true if _dirty: _refresh() else: _schedule_previews() _animate_open(base) _list.grab_focus() func close() -> void: if not is_open: return is_open = false _flush_cfg() _ctx_popup.hide() var base := EditorInterface.get_base_control() _animate_close(base) _tween.finished.connect(func() -> void: if not is_open: visible = false scale = Vector2.ONE _set_hint_visible(true) , CONNECT_ONE_SHOT) func _open_y(base: Control) -> float: return base.size.y - size.y - BOTTOM_GAP func _layout(base: Control) -> void: var h := clampf(base.size.y * _height_ratio, MIN_HEIGHT, base.size.y * 0.85) var w := maxf(minf(base.size.x - SIDE_GAP * 2.0, MAX_WIDTH), 320.0) size = Vector2(w, h) position.x = (base.size.x - size.x) * 0.5 position.y = _open_y(base) if is_open else base.size.y + 20.0 func _on_editor_resized() -> void: _layout_hint() if is_inside_tree() and is_open: _layout(EditorInterface.get_base_control()) # ---------- Hint pill (shortcut) ---------- func _build_hint(base: Control) -> void: var accent: Color = base.get_theme_color("accent_color", "Editor") _hint = PanelContainer.new() _hint.name = "AssetDrawerHint" _hint.z_index = 200 # above the drawer panel itself (z_index = 128) _hint.z_as_relative = false _hint.modulate.a = 0.65 _hint.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND _hint.tooltip_text = L.t("Cliquer pour ouvrir le tiroir d'assets") var hint_style := _make_stylebox(COLOR_HEADER, Color(accent.r, accent.g, accent.b, 0.7), 8, 1) _hint.add_theme_stylebox_override("panel", hint_style) var pad := _pad(10, 4) _hint.add_child(pad) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) pad.add_child(row) var title := Label.new() title.text = "Asset Drawer" title.add_theme_font_override("font", base.get_theme_font("bold", "EditorFonts")) title.add_theme_font_size_override("font_size", 12) row.add_child(title) var key := PanelContainer.new() key.add_theme_stylebox_override("panel", _make_stylebox(_ov(0.06), _ov(0.25), 4, 1)) var key_pad := _pad(6, 1) key.add_child(key_pad) var key_lbl := Label.new() key_lbl.text = L.t("Ctrl+Espace") key_lbl.modulate = Color(1, 1, 1, 0.8) key_lbl.add_theme_font_size_override("font_size", 11) key_pad.add_child(key_lbl) row.add_child(key) _ignore_mouse(pad) _hint.mouse_entered.connect(func() -> void: _hint.modulate.a = 1.0) _hint.mouse_exited.connect(func() -> void: _hint.modulate.a = 0.65) _hint.gui_input.connect(func(e: InputEvent) -> void: var mb := e as InputEventMouseButton if mb != null and mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT: open() ) base.add_child(_hint) _layout_hint.call_deferred() _breathe_hint(hint_style, accent) ## Slow, discreet breathing of the hint pill's outline, looping, ## to gently signal that it is clickable without being loud. func _breathe_hint(style: StyleBoxFlat, accent: Color) -> void: if _hint_tween and _hint_tween.is_valid(): _hint_tween.kill() _hint_tween = create_tween().set_loops() _hint_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) _hint_tween.tween_property(style, "border_color", Color(accent.r, accent.g, accent.b, 0.95), 1.4) _hint_tween.tween_property(style, "border_color", Color(accent.r, accent.g, accent.b, 0.45), 1.4) func _ignore_mouse(node: Node) -> void: if node is Control: (node as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE for c in node.get_children(): _ignore_mouse(c) func _layout_hint() -> void: if not is_instance_valid(_hint): return var base := EditorInterface.get_base_control() var sz := _hint.get_combined_minimum_size() _hint.size = sz _hint.position = Vector2((base.size.x - sz.x) * 0.5, base.size.y - sz.y - HINT_BOTTOM) func _set_hint_visible(on: bool) -> void: if is_instance_valid(_hint): _hint.visible = on if on: _layout_hint() ## Opening with a slight bounce ("overshoot"): livelier than a plain linear slide. func _animate_open(base: Control) -> void: if _tween: _tween.kill() pivot_offset = Vector2(size.x * 0.5, size.y) var dur := ANIM_TIME * 1.4 _tween = create_tween().set_parallel(true) _tween.tween_property(self, "position:y", _open_y(base), dur) \ .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _tween.tween_property(self, "scale", Vector2.ONE, dur) \ .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _tween.tween_property(self, "modulate:a", 1.0, dur * 0.55) \ .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) _tween.chain().tween_callback(_pulse_glow) ## Shorter, crisper closing, with a slight fold-back to accompany the exit. func _animate_close(base: Control) -> void: if _tween: _tween.kill() if _glow_tween and _glow_tween.is_valid(): _glow_tween.kill() var dur := ANIM_TIME * 0.85 _tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) _tween.tween_property(self, "position:y", base.size.y + 20.0, dur) _tween.tween_property(self, "scale", Vector2(0.972, 0.955), dur) _tween.tween_property(self, "modulate:a", 0.0, dur * 0.85) ## Pulse of the accent outline/shadow right after opening: a discreet luminous "pop". func _pulse_glow() -> void: if _panel_style == null or not is_open: return var accent: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") if _glow_tween and _glow_tween.is_valid(): _glow_tween.kill() _panel_style.shadow_size = 12 _glow_tween = create_tween() _glow_tween.set_trans(Tween.TRANS_SINE) _glow_tween.tween_property(_panel_style, "shadow_color", Color(accent.r, accent.g, accent.b, 0.35), 0.15).set_ease(Tween.EASE_OUT) _glow_tween.parallel().tween_property(_panel_style, "shadow_size", 16.0, 0.15).set_ease(Tween.EASE_OUT) _glow_tween.tween_property(_panel_style, "shadow_color", Color(0, 0, 0, 0.30), 0.6).set_ease(Tween.EASE_IN) _glow_tween.parallel().tween_property(_panel_style, "shadow_size", 12.0, 0.6).set_ease(Tween.EASE_IN) func _on_grip_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _resizing = event.pressed if not event.pressed: _save_cfg() elif event is InputEventMouseMotion and _resizing: var base := EditorInterface.get_base_control() var new_h: float = base.size.y - BOTTOM_GAP - (position.y + event.relative.y) _height_ratio = clampf(new_h / base.size.y, 0.04, 0.85) _layout(base) # ---------- Navigation ---------- func _navigate(path: String) -> void: var dir := path if DirAccess.dir_exists_absolute(path) else path.get_base_dir() if dir == "res:": dir = "res://" current_dir = dir _active_set = "" _search.text = "" if _history_i < _history.size() - 1: _history.resize(_history_i + 1) if _history[_history_i] != current_dir: _history.append(current_dir) _history_i = _history.size() - 1 _refresh() _save_cfg() func _hist_go(delta: int) -> void: var ni := _history_i + delta if ni >= 0 and ni < _history.size(): _history_i = ni current_dir = _history[_history_i] _active_set = "" _search.text = "" _refresh() func _go_up() -> void: if not _active_set.is_empty(): _active_set = "" _refresh() elif current_dir != "res://": _navigate(current_dir.trim_suffix("/").get_base_dir()) func _show_in_dock(path: String) -> void: var dock := EditorInterface.get_file_system_dock() if dock and dock.has_method("navigate_to_path"): dock.navigate_to_path(path) func _on_item_activated(index: int) -> void: _open_path(str(_list.get_item_metadata(index))) const MODEL_EXTS := ["glb", "gltf", "fbx", "blend", "dae", "obj"] const MODEL_SCENE_EXTS := ["glb", "gltf", "fbx", "blend", "dae"] # imported as scenes (obj = Mesh) func _is_model(path: String) -> bool: return path.get_extension().to_lower() in MODEL_EXTS func _is_model_scene(path: String) -> bool: return path.get_extension().to_lower() in MODEL_SCENE_EXTS ## Selects the file (the Import dock updates) then brings the Import tab to the front. func _show_import_settings(path: String) -> void: EditorInterface.select_file(path) get_tree().create_timer(0.08).timeout.connect(_focus_import_dock) if not pinned: close() func _focus_import_dock() -> void: var dock := _find_node_by_class(EditorInterface.get_base_control(), "ImportDock") if dock == null: return var child: Node = dock var parent: Node = dock.get_parent() while parent != null and not (parent is TabContainer): child = parent parent = parent.get_parent() if parent is TabContainer and child is Control: var tc := parent as TabContainer var idx := tc.get_tab_idx_from_control(child as Control) if idx >= 0: tc.current_tab = idx func _find_node_by_class(n: Node, cls: String) -> Node: if n.get_class() == cls: return n for c in n.get_children(): var r := _find_node_by_class(c, cls) if r != null: return r return null ## Opens Godot's "Advanced Import" window: selects the file, waits for the Import dock ## to show that file, then triggers its "Advanced..." button. func _open_advanced_import(path: String) -> void: if not _is_model_scene(path): _show_import_settings(path) # e.g. .obj: no advanced import, we show the Import dock return EditorInterface.select_file(path) if not pinned: close() _try_advanced_import(path, 0) func _try_advanced_import(path: String, attempt: int) -> void: await get_tree().create_timer(0.15).timeout # 1) Main method: reproduce exactly the double-click in the FileSystem dock, which itself # calls Godot's advanced import for 3D models. if _activate_in_filesystem_dock(path): return # 2) Fallback: the "Advanced..." button of the Import dock. var dock := _find_node_by_class(EditorInterface.get_base_control(), "ImportDock") if dock != null and _import_dock_shows(dock, path): var btn := _find_advanced_button(dock) if btn != null: btn.pressed.emit() return if attempt < 12: _try_advanced_import(path, attempt + 1) else: _focus_import_dock() _flash(L.t("Import avancé indisponible pour ce fichier"), true) ## Simulates a double-click on the file in the FileSystem dock (file list or tree). func _activate_in_filesystem_dock(path: String) -> bool: var fsd: Node = EditorInterface.get_file_system_dock() if fsd == null: return false return _activate_in_node(fsd, path) func _activate_in_node(n: Node, path: String) -> bool: if n is ItemList: var il := n as ItemList for i in il.item_count: var m: Variant = il.get_item_metadata(i) if (m is String or m is StringName) and str(m) == path: il.select(i) il.item_activated.emit(i) return true elif n is Tree: var t := n as Tree var root := t.get_root() if root != null: var it := _find_fs_tree_item(root, path) if it != null: it.select(0) t.item_activated.emit() return true for c in n.get_children(): if _activate_in_node(c, path): return true return false func _find_fs_tree_item(item: TreeItem, path: String) -> TreeItem: var m: Variant = item.get_metadata(0) if (m is String or m is StringName) and str(m) == path: return item for c in item.get_children(): var r := _find_fs_tree_item(c, path) if r != null: return r return null func _import_dock_shows(n: Node, path: String) -> bool: if n is Label and (n as Label).text.contains(path.get_file()): return true for c in n.get_children(): if _import_dock_shows(c, path): return true return false func _find_advanced_button(root: Node) -> Button: var buttons: Array[Button] = [] _collect_buttons(root, buttons) for b in buttons: var t := b.text.strip_edges() if b.visible and not b.disabled and (t.begins_with("Advanced") or t.begins_with("Avanc")): return b # Unknown language: the "Advanced..." button is the only plain button whose text ends with "..." for b in buttons: var t := b.text.strip_edges() if b.visible and not b.disabled and b.get_class() == "Button" and (t.ends_with("...") or t.ends_with("…")): return b return null func _collect_buttons(n: Node, out: Array[Button]) -> void: if n is Button: out.append(n as Button) for c in n.get_children(): _collect_buttons(c, out) ## Opens the model as a scene: Godot then offers to create a new inherited scene. func _new_inherited_scene(path: String) -> void: EditorInterface.open_scene_from_path(path) if not pinned: close() func _open_path(path: String) -> void: if path.is_empty(): return if DirAccess.dir_exists_absolute(path): _navigate(path) return _push_recent(path) var ext := path.get_extension().to_lower() if ext in ["tscn", "scn"]: EditorInterface.open_scene_from_path(path) elif _is_model(path): # Opens the model's "Advanced Import" window (creating an inherited scene # is in the right-click menu). _open_advanced_import(path) elif ResourceLoader.exists(path): var res := load(path) if res is Script: EditorInterface.edit_script(res) elif res is PackedScene: EditorInterface.open_scene_from_path(path) # .glb / .gltf / .fbx imported as scenes else: EditorInterface.edit_resource(res) else: OS.shell_open(ProjectSettings.globalize_path(path)) return if not pinned: close() # ---------- Keyboard & mouse ---------- func _input(event: InputEvent) -> void: if _ctx_popup != null and _ctx_popup.visible: if event is InputEventMouseButton and event.pressed: if not _ctx_popup.get_global_rect().has_point((event as InputEventMouseButton).global_position): _ctx_popup.hide() get_viewport().set_input_as_handled() return elif event is InputEventKey and event.pressed and (event as InputEventKey).keycode == KEY_ESCAPE: _ctx_popup.hide() get_viewport().set_input_as_handled() return # Mouse side buttons: back / forward (only over the drawer) if is_open and event is InputEventMouseButton and event.pressed: var xb := event as InputEventMouseButton if xb.button_index == MOUSE_BUTTON_XBUTTON1 or xb.button_index == MOUSE_BUTTON_XBUTTON2: if get_global_rect().has_point(xb.global_position): _hist_go(-1 if xb.button_index == MOUSE_BUTTON_XBUTTON1 else 1) get_viewport().set_input_as_handled() return # Click outside the drawer -> closes it (like Unreal's Content Drawer), unless pinned. # The event is not consumed: the click keeps its effect on whatever was clicked. if is_open and not pinned and event is InputEventMouseButton and event.pressed: var mb := event as InputEventMouseButton var is_wheel := mb.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN, MOUSE_BUTTON_WHEEL_LEFT, MOUSE_BUTTON_WHEEL_RIGHT, MOUSE_BUTTON_XBUTTON1, MOUSE_BUTTON_XBUTTON2] if not is_wheel and not _resizing and not _ctx_popup.visible and not get_global_rect().has_point(mb.global_position): _outside_close_msec = Time.get_ticks_msec() close() return if not is_open or not (event is InputEventKey) or not event.pressed or event.echo: return var k := event as InputEventKey var focus := get_viewport().gui_get_focus_owner() if focus != null and not is_ancestor_of(focus): return if k.ctrl_pressed and k.keycode == KEY_F: _search.grab_focus() _search.select_all() get_viewport().set_input_as_handled() return if focus is LineEdit or focus is TextEdit: return # Actions on items: only if the list has focus (not the tree nor a button) if focus != null and focus != _list and k.keycode != KEY_BACKSPACE: return var sel := _selected_paths() var handled := true match k.keycode: KEY_F2: if sel.size() == 1: _rename(sel[0]) KEY_DELETE: if not sel.is_empty(): _confirm_delete(sel) KEY_D: if k.ctrl_pressed and not sel.is_empty(): _duplicate(sel) else: handled = false KEY_BACKSPACE: _go_up() KEY_ENTER, KEY_KP_ENTER: if sel.size() == 1: _open_path(sel[0]) _: handled = false if handled: get_viewport().set_input_as_handled() func _on_list_gui_input(event: InputEvent) -> void: # --- Rectangle selection (left click held in an empty area) --- if event is InputEventMouseMotion and _mq_active: var mm := event as InputEventMouseMotion _marquee_update(mm.position, mm.button_mask) return if event is InputEventMouseButton and not event.pressed and _mq_active \ and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT: _marquee_end() return if not (event is InputEventMouseButton) or not event.pressed: return var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT and not mb.double_click \ and _list.item_count > 0 and _list.get_item_at_position(mb.position, true) < 0: _marquee_begin(mb) elif mb.button_index == MOUSE_BUTTON_RIGHT: var idx := _list.get_item_at_position(mb.position, true) if idx >= 0: if not _list.is_selected(idx): _list.deselect_all() _list.select(idx, true) _on_selection_changed() _context_path = str(_list.get_item_metadata(idx)) else: _list.deselect_all() _on_selection_changed() _context_path = current_dir _show_context() _list.accept_event() elif mb.ctrl_pressed and (mb.button_index == MOUSE_BUTTON_WHEEL_UP or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN): var zstep := 4.0 if _view_list else 8.0 _zoom.value += zstep if mb.button_index == MOUSE_BUTTON_WHEEL_UP else -zstep _list.accept_event() func _marquee_begin(mb: InputEventMouseButton) -> void: _mq_active = true _mq_moved = false var additive := mb.ctrl_pressed or mb.shift_pressed _mq_base = _list.get_selected_items() if additive else PackedInt32Array() _mq_start = Vector2(mb.position.x, mb.position.y + _list.get_v_scroll_bar().value) _mq_cur = mb.position func _marquee_update(pos: Vector2, mask: int) -> void: if (mask & MOUSE_BUTTON_MASK_LEFT) == 0: _marquee_end() return _mq_cur = pos var vs := _list.get_v_scroll_bar() if not _mq_moved: if Vector2(_mq_start.x, _mq_start.y - vs.value).distance_to(pos) < 4.0: return _mq_moved = true # Auto-scroll near the top / bottom edges if pos.y < 14.0: vs.value -= 18.0 elif pos.y > _list.size.y - 14.0: vs.value += 18.0 var sv := Vector2(_mq_start.x, _mq_start.y - vs.value) var rect := Rect2(sv, pos - sv).abs() _list.deselect_all() for i in _mq_base: _list.select(i, false) for i in _list.item_count: var r := _item_rect(i, true).grow(-4.0) if r.position.y > rect.end.y: break if r.end.y < rect.position.y: continue if r.intersects(rect): _list.select(i, false) _list.queue_redraw() func _marquee_end() -> void: var was_moved := _mq_moved _mq_active = false _mq_moved = false _list.queue_redraw() if was_moved: _on_selection_changed() func _get_drag_data_fw(_at: Vector2) -> Variant: if _mq_active: return null var files := _selected_paths() if files.is_empty(): return null var base := EditorInterface.get_base_control() var accent: Color = base.get_theme_color("accent_color", "Editor") var first: String = files[0] var is_dir := DirAccess.dir_exists_absolute(first) # Small floating card (icon + name, slightly tilted) rather than a bare Label. var wrap := Control.new() var card := PanelContainer.new() card.rotation = deg_to_rad(-3.0) card.add_theme_stylebox_override("panel", _make_stylebox(COLOR_CARD_BG, Color(accent.r, accent.g, accent.b, 0.9), 7, 1)) var pad := _pad(8, 5) card.add_child(pad) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) pad.add_child(row) var icon := TextureRect.new() icon.custom_minimum_size = Vector2(18, 18) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture = base.get_theme_icon("Folder" if is_dir else "File", "EditorIcons") if is_dir: icon.modulate = _folder_color(_colors(), first) row.add_child(icon) var lbl := Label.new() lbl.text = first.trim_suffix("/").get_file() + (" (+%d)" % (files.size() - 1) if files.size() > 1 else "") lbl.add_theme_font_size_override("font_size", 12) row.add_child(lbl) wrap.add_child(card) set_drag_preview(wrap) return {"type": "files", "files": files} # ---------- Drag and drop: move files / folders into a folder ---------- func _notification(what: int) -> void: if what == NOTIFICATION_DRAG_END: _clear_drop_hl() # Paths contained in a drag (drawer or Godot's FileSystem dock), without trailing "/" func _drag_paths(data: Variant) -> PackedStringArray: var out := PackedStringArray() if data is Dictionary: var t := str(data.get("type", "")) if t == "files" or t == "files_and_dirs": for f in data.get("files", []): var p := str(f) if p != "res://": p = p.trim_suffix("/") if not p.is_empty() and not out.has(p): out.append(p) return out func _norm_dir(p: String) -> String: if p == "res://" or p == "res:/": return "res://" return p.trim_suffix("/") func _can_move_one(src: String, dst_dir: String) -> bool: if src.is_empty() or src == "res://": return false if src.get_base_dir() == dst_dir or src == dst_dir: return false if _is_under(dst_dir, src): return false # a folder cannot go into itself / one of its subfolders return FileAccess.file_exists(src) or DirAccess.dir_exists_absolute(src) func _can_move_any(files: PackedStringArray, dst_dir: String) -> bool: for f in files: if _can_move_one(f, dst_dir): return true return false func _drop_hl_color() -> Color: var acc: Color = EditorInterface.get_base_control().get_theme_color("accent_color", "Editor") return Color(acc.r, acc.g, acc.b, 0.38) func _set_drop_hl_list(idx: int, whole: bool = false) -> void: if _list == null: return if idx == _drop_list_idx and whole == _drop_whole: return _drop_list_idx = idx _drop_whole = whole _list.queue_redraw() func _set_drop_hl_tree(item: TreeItem) -> void: if item == _drop_tree_item: return _drop_tree_item = item if _tree != null: _tree.queue_redraw() func _clear_drop_hl() -> void: _set_drop_hl_list(-1) _set_drop_hl_tree(null) # Target of a drop in the list: hovered folder, otherwise the current folder (empty area) func _list_drop_target(at: Vector2) -> Dictionary: var idx := _list.get_item_at_position(at, true) if idx >= 0: var p := str(_list.get_item_metadata(idx)) if _dir_paths.has(p): return {"dir": _norm_dir(p), "idx": idx} # Empty area (or file): we drop into the current folder, except in search / set, # where the list mixes several folders. if not _active_set.is_empty() or not _search.text.strip_edges().is_empty(): return {"dir": "", "idx": -1} return {"dir": _norm_dir(current_dir), "idx": -1} func _list_can_drop(at: Vector2, data: Variant) -> bool: var files := _drag_paths(data) if files.is_empty(): _set_drop_hl_list(-1) return false var tgt := _list_drop_target(at) var dst: String = tgt["dir"] var ok := not dst.is_empty() and _can_move_any(files, dst) _set_drop_hl_list(int(tgt["idx"]) if ok else -1, ok and int(tgt["idx"]) < 0) return ok func _list_drop(at: Vector2, data: Variant) -> void: var files := _drag_paths(data) var dst: String = _list_drop_target(at)["dir"] _clear_drop_hl() if dst.is_empty(): return _move_paths(files, dst) func _tree_drop_dir(at: Vector2) -> String: var it := _tree.get_item_at_position(at) if it == null: return "" return _norm_dir(str(it.get_metadata(0))) func _tree_can_drop(at: Vector2, data: Variant) -> bool: var files := _drag_paths(data) var it := _tree.get_item_at_position(at) if files.is_empty() or it == null: _set_drop_hl_tree(null) return false var ok := _can_move_any(files, _norm_dir(str(it.get_metadata(0)))) _set_drop_hl_tree(it if ok else null) return ok func _tree_drop(at: Vector2, data: Variant) -> void: var files := _drag_paths(data) var dst := _tree_drop_dir(at) _clear_drop_hl() if dst.is_empty(): return _move_paths(files, dst) # You can also grab a folder in the tree to move it into another folder func _tree_get_drag(at: Vector2) -> Variant: var it := _tree.get_item_at_position(at) if it == null: return null var p := _norm_dir(str(it.get_metadata(0))) if p == "res://": return null var lbl := Label.new() lbl.text = " " + p.get_file() + " " lbl.add_theme_stylebox_override("normal", _make_stylebox(COLOR_CARD_BG, _drop_hl_color(), 6, 1)) set_drag_preview(lbl) return {"type": "files", "files": PackedStringArray([p])} func _move_paths(files: PackedStringArray, dst_dir: String) -> void: EditorInterface.save_all_scenes() # <-- ADDED: avoids losing changes on reload var moved := 0 var moves: Array = [] # <-- ADDED for p in files: var src := p.trim_suffix("/") if not _can_move_one(src, dst_dir): continue var dst := dst_dir.path_join(src.get_file()) if FileAccess.file_exists(dst) or DirAccess.dir_exists_absolute(dst): _flash(L.t("« %s » existe déjà") % src.get_file(), true) continue var is_dir := DirAccess.dir_exists_absolute(src) # <-- ADDED (before the rename) if DirAccess.rename_absolute(src, dst) != OK: _flash(L.t("Échec du déplacement"), true) continue moves.append([src, dst, is_dir]) # <-- ADDED for ext in [".import", ".uid"]: if FileAccess.file_exists(src + ext): DirAccess.rename_absolute(src + ext, dst + ext) _rewrite_paths(src, dst) current_dir = _swap_prefix(current_dir, src, dst) for i in _history.size(): _history[i] = _swap_prefix(_history[i], src, dst) moved += 1 if moved == 0: return _update_references(moves) # <-- ADDED _scan() _flash(L.t("%d élément(s) déplacé(s)") % moved if moved > 1 else L.t("Élément déplacé")) # ---------- Context menu ---------- func _ctx_btn(title: String, callback: Callable, color: Color = Color.TRANSPARENT, icon_name: String = "") -> Button: var b := Button.new() b.text = title b.alignment = HORIZONTAL_ALIGNMENT_LEFT _style_ghost_button(b, 8, 3) if color != Color.TRANSPARENT: b.add_theme_color_override("font_color", color) if not icon_name.is_empty(): var base := EditorInterface.get_base_control() if base.has_theme_icon(icon_name, "EditorIcons"): b.icon = base.get_theme_icon(icon_name, "EditorIcons") b.add_theme_constant_override("h_separation", 8) b.pressed.connect(func() -> void: _ctx_popup.hide() callback.call() ) _ctx_vbox.add_child(b) return b func _ctx_sep() -> void: _ctx_vbox.add_child(HSeparator.new()) ## Checkbox menu entry. func _ctx_check(title: String, checked: bool, on_toggle: Callable) -> CheckBox: var cb := CheckBox.new() cb.text = title cb.alignment = HORIZONTAL_ALIGNMENT_LEFT cb.focus_mode = Control.FOCUS_NONE cb.button_pressed = checked _style_ghost_button(cb, 8, 3) # The theme colors the text of a checked button: we keep the normal color var fc: Color = EditorInterface.get_base_control().get_theme_color("font_color", "Button") for n in ["font_pressed_color", "font_hover_pressed_color", "font_focus_color"]: cb.add_theme_color_override(n, fc) cb.toggled.connect(func(on: bool) -> void: _ctx_popup.hide() on_toggle.call(on) ) _ctx_vbox.add_child(cb) return cb ## Loads the resource in memory and keeps it as the drawer's "clipboard". ## A "Paste" button then appears when hovering any compatible resource field ## of the Inspector (or Alt+V as a keyboard shortcut). func _copy_resource_to_clipboard(path: String) -> void: if DirAccess.dir_exists_absolute(path): _flash(L.t("Impossible de copier un dossier comme ressource"), true) return if not ResourceLoader.exists(path): _flash(L.t("Ce fichier n'est pas une ressource Godot"), true) return var res := load(path) if res == null or not (res is Resource): _flash(L.t("Impossible de charger la ressource"), true) return _clip_resource = res _clip_resource_name = path.get_file() _flash(L.t("« %s » copiée — survolez un champ ressource de l'Inspecteur") % _clip_resource_name) ## True if `res` matches at least one of the types allowed by an EditorResourcePicker ## (native types via is_class, and custom script classes via their global class_name). func _resource_type_matches(res: Resource, allowed: PackedStringArray) -> bool: if allowed.is_empty(): return true var script := res.get_script() var global_name := "" if script is Script and (script as Script).has_method("get_global_name"): global_name = (script as Script).get_global_name() for t in allowed: var tn: String = String(t).strip_edges() if tn.is_empty(): continue if res.is_class(tn): return true if not global_name.is_empty() and global_name == tn: return true return false ## Walks up the parent chain from the hovered control to find an ## EditorResourcePicker (the widget of a resource field in the Inspector). func _picker_under(control: Control) -> EditorResourcePicker: var n: Node = control while n: if n is EditorResourcePicker: return n n = n.get_parent() return null func _picker_accepts_clip(picker: EditorResourcePicker) -> bool: if picker == null or not is_instance_valid(picker) or _clip_resource == null: return false return picker.editable and _resource_type_matches(_clip_resource, picker.get_allowed_types()) ## Looks for an EditorResourcePicker (Inspector resource field) under the mouse ## and assigns the copied resource to it if the type matches. Alt+V keyboard shortcut (plugin.gd); ## the floating "Paste" button that appears on hover does the same on click. func try_paste_resource_at_mouse() -> bool: if _clip_resource == null or not is_instance_valid(_clip_resource): _flash(L.t("Aucune ressource copiée : clic droit sur un fichier → « Copier la ressource »"), true) return false var vp := EditorInterface.get_base_control().get_viewport() var hovered: Control = vp.gui_get_hovered_control() if vp else null var picker := _picker_under(hovered) if picker == null: _flash(L.t("Survolez un champ ressource de l'Inspecteur puis Alt+V"), true) return false if not picker.editable: _flash(L.t("Ce champ n'est pas modifiable"), true) return false var allowed := picker.get_allowed_types() if not _resource_type_matches(_clip_resource, allowed): var attendu := ", ".join(allowed) if not allowed.is_empty() else "?" _flash(L.t("Type incompatible : ce champ attend « %s »") % attendu, true) return false # set_edited_resource() only refreshes the field's display: without emitting # resource_changed, the Inspector never propagates the value to the object's real property # (e.g. MeshInstance3D.mesh), which then overwrites the display on the next refresh. picker.set_edited_resource(_clip_resource) picker.emit_signal("resource_changed", _clip_resource) _flash(L.t("« %s » collée dans l'Inspecteur") % _clip_resource_name) return true ## Lightweight loop that makes the floating "Paste" button appear/follow above ## any compatible hovered resource field (only when a resource is copied). func _update_paste_pop_hover() -> void: if _clip_resource == null or not is_instance_valid(_clip_resource): if _paste_pop.visible: _hide_paste_pop() return var vp := EditorInterface.get_base_control().get_viewport() var hovered: Control = vp.gui_get_hovered_control() if vp else null # Do not hide while heading to the button to click it. if hovered != null and (hovered == _paste_pop or _paste_pop.is_ancestor_of(hovered)): return var picker := _picker_under(hovered) if not _picker_accepts_clip(picker): if _paste_pop.visible: _hide_paste_pop() return var is_new := picker != _paste_target _paste_target = picker _position_paste_pop(picker) if is_new or not _paste_pop.visible: _show_paste_pop() func _position_paste_pop(picker: EditorResourcePicker) -> void: var rect := picker.get_global_rect() var origin: Vector2 = _toast_overlay.global_position var btn_size := _paste_pop.size # Left side of the field, vertically centered: the right stays free for Godot's native # quick menu and dropdown arrow. Always clamped inside the field. var x: float = rect.position.x + 2.0 x = minf(x, rect.position.x + rect.size.x - btn_size.x - 2.0) x = maxf(x, rect.position.x + 1.0) var y: float = rect.position.y + (rect.size.y - btn_size.y) * 0.5 y = clampf(y, rect.position.y + 1.0, rect.position.y + maxf(rect.size.y - btn_size.y - 1.0, 1.0)) var pos := Vector2(x, y) - origin _paste_pop.position = pos func _show_paste_pop() -> void: _paste_hiding = false if _paste_tween and _paste_tween.is_valid(): _paste_tween.kill() _paste_pop.visible = true _paste_pop.pivot_offset = _paste_pop.size * 0.5 _paste_pop.modulate.a = 0.0 _paste_pop.scale = Vector2(0.8, 0.8) _paste_tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _paste_tween.tween_property(_paste_pop, "modulate:a", 1.0, 0.12) _paste_tween.parallel().tween_property(_paste_pop, "scale", Vector2.ONE, 0.16) func _hide_paste_pop() -> void: _paste_target = null if _paste_hiding or not is_instance_valid(_paste_pop): return if _paste_tween and _paste_tween.is_valid(): _paste_tween.kill() _paste_hiding = true _paste_tween = create_tween().set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) _paste_tween.tween_property(_paste_pop, "modulate:a", 0.0, 0.08) _paste_tween.tween_callback(func() -> void: _paste_hiding = false if is_instance_valid(_paste_pop): _paste_pop.visible = false ) func _on_paste_pop_pressed() -> void: if not _picker_accepts_clip(_paste_target): _hide_paste_pop() return _paste_target.set_edited_resource(_clip_resource) _paste_target.emit_signal("resource_changed", _clip_resource) _flash(L.t("« %s » collée dans l'Inspecteur") % _clip_resource_name) _hide_paste_pop() ## Lightweight loop that makes the floating "Copy" button appear/follow above ## the drawer's list resource currently hovered by the mouse. func _update_copy_pop_hover() -> void: var vp := EditorInterface.get_base_control().get_viewport() var hovered: Control = vp.gui_get_hovered_control() if vp else null # Do not hide while heading to the button to click it. if hovered != null and (hovered == _copy_pop or _copy_pop.is_ancestor_of(hovered)): return if hovered == null or not (hovered == _list or _list.is_ancestor_of(hovered)): if _copy_pop.visible: _hide_copy_pop() return var idx := _list.get_item_at_position(_list.get_local_mouse_position(), true) if idx < 0: if _copy_pop.visible: _hide_copy_pop() return var path := str(_list.get_item_metadata(idx)) if path.is_empty() or DirAccess.dir_exists_absolute(path) or not ResourceLoader.exists(path): if _copy_pop.visible: _hide_copy_pop() return if not _position_copy_pop(idx): # item (almost) entirely out of the visible area: no button if _copy_pop.visible: _hide_copy_pop() return var is_new := path != _copy_target_path or not _copy_pop.visible _copy_target_path = path if is_new: _show_copy_pop() func _position_copy_pop(idx: int) -> bool: # The button lives in an unclipped layer: we explicitly keep it inside the # VISIBLE part of the list (a file half out at the top must not make it overflow onto # the search bar / the path). Returns false if there is no visible room. var item_rect := _item_rect(idx, false) var vbar := _list.get_v_scroll_bar() var view_w := _list.size.x - (vbar.size.x if vbar.visible else 0.0) var view := Rect2(Vector2.ZERO, Vector2(view_w, _list.size.y)) var vis := item_rect.intersection(view) var btn_size := _copy_pop.size if vis.size.x < 8.0 or vis.size.y < minf(btn_size.y * 0.6, 10.0): return false var origin: Vector2 = _toast_overlay.global_position var list_origin: Vector2 = _list.global_position # Top-right corner of the visible part of the item var x: float = vis.end.x - btn_size.x - 3.0 x = maxf(x, vis.position.x + 1.0) var y: float = vis.position.y + 3.0 y = minf(y, vis.position.y + maxf(vis.size.y - btn_size.y - 1.0, 1.0)) # Final safeguard: never outside the list x = clampf(x, 0.0, maxf(view_w - btn_size.x, 0.0)) y = clampf(y, 0.0, maxf(_list.size.y - btn_size.y, 0.0)) _copy_pop.position = list_origin + Vector2(x, y) - origin return true func _show_copy_pop() -> void: _copy_hiding = false if _copy_tween and _copy_tween.is_valid(): _copy_tween.kill() _copy_pop.visible = true _copy_pop.pivot_offset = _copy_pop.size * 0.5 _copy_pop.modulate.a = 0.0 _copy_pop.scale = Vector2(0.8, 0.8) _copy_tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _copy_tween.tween_property(_copy_pop, "modulate:a", 1.0, 0.12) _copy_tween.parallel().tween_property(_copy_pop, "scale", Vector2.ONE, 0.16) func _hide_copy_pop() -> void: _copy_target_path = "" # Already fading out: above all, do not restart the tween (the hover loop # recreated it every frame, which froze the fade and made it very slow). if _copy_hiding or not is_instance_valid(_copy_pop): return if _copy_tween and _copy_tween.is_valid(): _copy_tween.kill() _copy_hiding = true _copy_tween = create_tween().set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) _copy_tween.tween_property(_copy_pop, "modulate:a", 0.0, 0.08) _copy_tween.tween_callback(func() -> void: _copy_hiding = false if is_instance_valid(_copy_pop): _copy_pop.visible = false ) func _on_copy_pop_pressed() -> void: if _copy_target_path.is_empty(): _hide_copy_pop() return _copy_resource_to_clipboard(_copy_target_path) _bounce_to(_copy_pop, Vector2(1.2, 1.2), 0.08) get_tree().create_timer(0.08).timeout.connect(func() -> void: if is_instance_valid(_copy_pop): _bounce_to(_copy_pop, Vector2.ONE, 0.14) ) func _place_ctx_popup() -> void: _ctx_popup.visible = true _ctx_popup.reset_size() _ctx_popup.size = _ctx_popup.get_combined_minimum_size() var ov: Vector2 = _toast_overlay.size var pos: Vector2 = _toast_overlay.get_local_mouse_position() var sz: Vector2 = _ctx_popup.size pos.x = clampf(pos.x, 4.0, maxf(4.0, ov.x - sz.x - 4.0)) pos.y = clampf(pos.y, 4.0, maxf(4.0, ov.y - sz.y - 4.0)) _ctx_popup.position = pos _ctx_popup.modulate.a = 0.0 create_tween().tween_property(_ctx_popup, "modulate:a", 1.0, 0.08) func _add_color_row(target: String) -> void: var color_lbl := Label.new() color_lbl.text = L.t("COULEUR DU DOSSIER") color_lbl.modulate = TEXT_MUTED color_lbl.add_theme_font_size_override("font_size", 10) _ctx_vbox.add_child(color_lbl) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 4) for key in FOLDER_COLORS.keys(): var cb := Button.new() cb.custom_minimum_size = Vector2(16, 16) cb.flat = false cb.tooltip_text = str(key) cb.add_theme_stylebox_override("normal", _make_stylebox(FOLDER_COLORS[key], Color.TRANSPARENT, 8)) cb.add_theme_stylebox_override("hover", _make_stylebox(FOLDER_COLORS[key], Color.WHITE, 8, 2)) _add_press_bounce(cb, 1.25, 0.85) var ck: String = key cb.pressed.connect(func() -> void: _ctx_popup.hide() _set_folder_color(target, ck) ) row.add_child(cb) var rb := Button.new() rb.custom_minimum_size = Vector2(16, 16) rb.flat = false rb.tooltip_text = L.t("Couleur par défaut") rb.add_theme_stylebox_override("normal", _make_stylebox(Color(0.2, 0.2, 0.2), Color.GRAY, 8, 1)) _add_press_bounce(rb, 1.25, 0.85) rb.pressed.connect(func() -> void: _ctx_popup.hide() _set_folder_color(target, "") ) row.add_child(rb) _ctx_vbox.add_child(row) func _show_context() -> void: for c in _ctx_vbox.get_children(): _ctx_vbox.remove_child(c) c.queue_free() var sel := _selected_paths() var on_item := not sel.is_empty() var target := _context_path var is_dir := DirAccess.dir_exists_absolute(target) var single := sel.size() == 1 if on_item: if single: _ctx_btn(L.t("Ouvrir"), func() -> void: _open_path(target), Color.TRANSPARENT, "Load") if not is_dir and _is_model(target): _ctx_btn(L.t("Import avancé..."), func() -> void: _open_advanced_import(target), Color.TRANSPARENT, "Tools") if _is_model_scene(target): _ctx_btn(L.t("Nouvelle scène héritée"), func() -> void: _new_inherited_scene(target), Color.TRANSPARENT, "PackedScene") _ctx_btn(L.t("Afficher dans le dock Fichiers"), func() -> void: _show_in_dock(target), Color.TRANSPARENT, "Filesystem") _ctx_btn(L.t("Afficher dans l'explorateur"), func() -> void: OS.shell_show_in_file_manager(ProjectSettings.globalize_path(target.trim_suffix("/"))) , Color.TRANSPARENT, "ExternalLink") if single and _active_set.is_empty() and not _search.text.strip_edges().is_empty(): _ctx_btn(L.t("Afficher dans son dossier"), func() -> void: _pending_select = target _navigate(target) , Color.TRANSPARENT, "Folder") _ctx_sep() _ctx_btn(L.t("Copier le chemin"), func() -> void: DisplayServer.clipboard_set("\n".join(sel)) _flash(L.t("Chemin copié")) , Color.TRANSPARENT, "ActionCopy") _ctx_btn(L.t("Copier le chemin absolu"), func() -> void: var abs_paths: PackedStringArray = [] for p in sel: abs_paths.append(ProjectSettings.globalize_path(p)) DisplayServer.clipboard_set("\n".join(abs_paths)) _flash(L.t("Chemin absolu copié")) , Color.TRANSPARENT, "ActionCopy") if single and not is_dir: _ctx_btn(L.t("Copier la ressource (bouton Coller au survol)"), func() -> void: _copy_resource_to_clipboard(target) , COLOR_RES_ACTION, "ActionCopy") if single and not is_dir and ResourceLoader.has_method("get_resource_uid"): _ctx_btn(L.t("Copier l'UID"), func() -> void: var uid: int = ResourceLoader.call("get_resource_uid", target) if uid != -1: DisplayServer.clipboard_set(ResourceUID.id_to_text(uid)) _flash(L.t("UID copié")) , Color.TRANSPARENT, "ActionCopy") _ctx_sep() if not on_item: var in_set := not _active_set.is_empty() var here := current_dir if not in_set: _ctx_btn(L.t("Ouvrir le dossier dans l'explorateur"), func() -> void: OS.shell_show_in_file_manager(ProjectSettings.globalize_path(here)) , Color.TRANSPARENT, "ExternalLink") _ctx_btn(L.t("Afficher dans le dock Fichiers"), func() -> void: _show_in_dock(here), Color.TRANSPARENT, "Filesystem") _ctx_btn(L.t("Copier le chemin du dossier"), func() -> void: DisplayServer.clipboard_set(here) _flash(L.t("Chemin copié")) , Color.TRANSPARENT, "ActionCopy") _ctx_btn(L.t("Copier le chemin absolu du dossier"), func() -> void: DisplayServer.clipboard_set(ProjectSettings.globalize_path(here)) _flash(L.t("Chemin absolu copié")) , Color.TRANSPARENT, "ActionCopy") if here != "res://": _ctx_btn(L.t("Retirer le dossier des favoris") if _is_fav(here) else L.t("Ajouter le dossier aux favoris"), func() -> void: var was_fav := _is_fav(here) _toggle_favorite(here) _refresh() _flash(L.t("Retiré des favoris") if was_fav else L.t("Ajouté aux favoris")) , Color.TRANSPARENT, "Favorites") _ctx_sep() _ctx_btn(L.t("Actualiser"), func() -> void: _scan() _refresh() _flash(L.t("Actualisé")) , Color.TRANSPARENT, "Reload") _ctx_btn(L.t("Passer en vue grille") if _view_list else L.t("Passer en vue liste"), func() -> void: var btn: Button = _btn_grid if _view_list else _btn_list btn.button_pressed = true btn.pressed.emit() , Color.TRANSPARENT, "Grid" if _view_list else "FileList") _ctx_check(L.t("Afficher les éléments masqués"), _show_hidden, _set_show_hidden) _ctx_sep() _ctx_btn(L.t("Nouveau dossier"), _create_folder, Color.TRANSPARENT, "Folder") _ctx_btn(L.t("Nouvelle scène"), _create_scene, Color.TRANSPARENT, "PackedScene") _ctx_btn(L.t("Nouveau script"), _create_script, Color.TRANSPARENT, "Script") if not on_item and _active_set.is_empty() and current_dir != "res://": _ctx_sep() _add_color_row(current_dir) if on_item: _ctx_sep() if single: _ctx_btn(L.t("Renommer (F2)"), func() -> void: _rename(target), Color.TRANSPARENT, "Rename") _ctx_btn(L.t("Dupliquer (Ctrl+D)"), func() -> void: _duplicate(sel), Color.TRANSPARENT, "Duplicate") _ctx_btn(L.t("Retirer des favoris") if _is_fav(target) else L.t("Ajouter aux favoris"), func() -> void: var was_fav := _is_fav(target) for p in sel: _toggle_favorite(p) _refresh() _flash(L.t("Retiré des favoris") if was_fav else L.t("Ajouté aux favoris")) , Color.TRANSPARENT, "Favorites") _ctx_btn(L.t("Ajouter à un set..."), func() -> void: var default_name := str(_sets.keys()[0]) if not _sets.is_empty() else L.t("mon_set") _prompt_string(L.t("Ajouter au set"), default_name, func(n: String) -> void: if not _valid_name(n): return var arr: Array = _sets.get(n, []) for p in sel: if not arr.has(p): arr.append(p) _sets[n] = arr _save_cfg() _refresh_left_tab_content() _flash(L.t("Ajouté au set « %s »") % n) ) , Color.TRANSPARENT, "Groups") if not _active_set.is_empty(): _ctx_btn(L.t("Retirer de ce set"), func() -> void: var arr: Array = _sets.get(_active_set, []) for p in sel: arr.erase(p) _sets[_active_set] = arr _save_cfg() _refresh() ) var to_hide: PackedStringArray = [] var to_unhide: PackedStringArray = [] for hp in sel: if _hidden.has(_hidden_key(hp)): to_unhide.append(hp) elif hp != "res://" and not _is_hidden(hp): to_hide.append(hp) if not to_hide.is_empty(): _ctx_btn(L.t("Masquer dans le tiroir"), func() -> void: _hide_paths(to_hide, true), Color.TRANSPARENT, "GuiVisibilityHidden") if not to_unhide.is_empty(): _ctx_btn(L.t("Ne plus masquer"), func() -> void: _hide_paths(to_unhide, false), Color.TRANSPARENT, "GuiVisibilityVisible") _ctx_check(L.t("Afficher les éléments masqués"), _show_hidden, _set_show_hidden) if not is_dir: _ctx_btn(L.t("Réimporter"), func() -> void: var files: PackedStringArray = [] for p in sel: if not DirAccess.dir_exists_absolute(p): files.append(p) EditorInterface.get_resource_filesystem().reimport_files(files) , Color.TRANSPARENT, "Reload") if is_dir and single: _ctx_sep() _add_color_row(target) _ctx_sep() _ctx_btn(L.t("Supprimer (Suppr)"), func() -> void: _confirm_delete(sel), COLOR_DANGER, "Remove") _place_ctx_popup() # PopupPanel is a Window (no "scale"/"modulate"): we animate its content instead. _ctx_vbox.modulate.a = 0.0 create_tween().tween_property(_ctx_vbox, "modulate:a", 1.0, 0.11) \ .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) # ---------- Creation / renaming / deletion ---------- func _valid_name(n: String) -> bool: if n.strip_edges().is_empty(): return false for ch in ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"]: if n.contains(ch): _flash(L.t("Nom invalide : caractère « %s » interdit") % ch, true) return false return true func _unique_path(dir: String, base_name: String, ext: String) -> String: var p := dir.path_join(base_name + ext) var i := 2 while FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p): p = dir.path_join("%s_%d%s" % [base_name, i, ext]) i += 1 return p func _scan() -> void: EditorInterface.get_resource_filesystem().scan() func _target_dir() -> String: # If a folder is selected we create inside it, otherwise in the current folder return current_dir func _create_folder() -> void: var dir := _target_dir() _prompt_string(L.t("Nouveau dossier"), _unique_path(dir, L.t("nouveau_dossier"), "").get_file(), func(n: String) -> void: if not _valid_name(n): return var p := dir.path_join(n) if DirAccess.dir_exists_absolute(p) or FileAccess.file_exists(p): _flash(L.t("« %s » existe déjà") % n, true) return DirAccess.make_dir_recursive_absolute(p) _pending_select = p _scan() ) func _create_scene() -> void: var dir := _target_dir() _prompt_string(L.t("Nouvelle scène"), _unique_path(dir, L.t("nouvelle_scene"), ".tscn").get_file(), func(n: String) -> void: if not _valid_name(n): return var p := dir.path_join(n if n.ends_with(".tscn") else n + ".tscn") if FileAccess.file_exists(p): _flash(L.t("« %s » existe déjà") % n, true) return var node := Node2D.new() node.name = p.get_file().get_basename().to_pascal_case() var packed := PackedScene.new() packed.pack(node) ResourceSaver.save(packed, p) node.free() _pending_select = p _scan() ) ## True only if the project already has a C# solution (a .csproj file at the root). ## Without it, Godot does not index .cs scripts in its EditorFileSystem: the file ## would exist on disk but stay invisible everywhere in the editor, not only ## in the drawer (that is why we block creation rather than let a "ghost" ## file be created that would later make people think it already exists). func _has_csharp_project() -> bool: var d := DirAccess.open("res://") if d == null: return false d.list_dir_begin() var f := d.get_next() while f != "": if not d.current_is_dir() and f.get_extension().to_lower() == "csproj": return true f = d.get_next() return false ## Languages offered in the dropdown: only those THIS Godot build knows ## about (ScriptServer only lists "C#" on the .NET edition, for example), exactly ## like Godot's native "Create Script" dialog does. We add "Shader" (not a ## ScriptLanguage in its own right) and "Custom..." (free extension) at the end. ## Languages offered in the dropdown: only those THIS Godot build knows ## about. `ScriptServer` (used internally by the native "Create Script" dialog) is not ## exposed to GDScript, so we detect differently: GDScript is always there, and C# only ## if this build has the "mono" feature tag (the .NET editions of Godot define it). ## Each entry is either {"sep": "Category name"} (a non-selectable separator in ## the menu), or {"name": ..., "ext": ...} (a real option). func _script_languages() -> Array: var out: Array = [] out.append({"sep": L.t("Scripts")}) out.append({"name": "GDScript", "ext": "gd"}) if OS.has_feature("mono"): out.append({"name": "C#", "ext": "cs"}) out.append({"name": L.t("Shader"), "ext": "gdshader"}) # Common text file types, not tied to a particular script language # (creating a simple .txt or .json next to the assets is a frequent need). out.append({"sep": L.t("Fichiers texte")}) out.append({"name": L.t("Texte"), "ext": "txt"}) out.append({"name": "JSON", "ext": "json"}) out.append({"name": "Markdown", "ext": "md"}) out.append({"name": L.t("Configuration"), "ext": "cfg"}) out.append({"name": "XML", "ext": "xml"}) out.append({"name": "CSV", "ext": "csv"}) out.append({"sep": ""}) out.append({"name": L.t("Personnalisé..."), "ext": ""}) return out func _create_script() -> void: var dir := _target_dir() var langs := _script_languages() var d := ConfirmationDialog.new() d.title = L.t("Nouveau script") d.ok_button_text = L.t("Créer") d.cancel_button_text = L.t("Annuler") var vb := VBoxContainer.new() vb.custom_minimum_size.x = 340 vb.add_theme_constant_override("separation", 8) d.add_child(vb) var lang_row := HBoxContainer.new() lang_row.add_theme_constant_override("separation", 8) vb.add_child(lang_row) var lang_lbl := Label.new() lang_lbl.text = L.t("Langage") lang_lbl.custom_minimum_size.x = 70 lang_row.add_child(lang_lbl) var lang_opt := OptionButton.new() lang_opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Mapping between the menu index (category separators count as one # slot, even though they cannot be selected) and the real index in "langs": -1 for # a separator, otherwise the index of the matching entry in "langs". var entry_of_index: Array = [] for i in langs.size(): var entry: Dictionary = langs[i] if entry.has("sep"): lang_opt.add_separator(entry.sep) entry_of_index.append(-1) else: lang_opt.add_item(entry.name) entry_of_index.append(i) lang_row.add_child(lang_opt) var custom_row := HBoxContainer.new() custom_row.add_theme_constant_override("separation", 8) custom_row.visible = false vb.add_child(custom_row) var custom_lbl := Label.new() custom_lbl.text = L.t("Extension") custom_lbl.custom_minimum_size.x = 70 custom_row.add_child(custom_lbl) var custom_edit := LineEdit.new() custom_edit.placeholder_text = "txt" custom_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL custom_row.add_child(custom_edit) var name_row := HBoxContainer.new() name_row.add_theme_constant_override("separation", 8) vb.add_child(name_row) var name_lbl := Label.new() name_lbl.text = L.t("Nom") name_lbl.custom_minimum_size.x = 70 name_row.add_child(name_lbl) var name_edit := LineEdit.new() name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL name_row.add_child(name_edit) d.register_text_enter(name_edit) # Preselects the last language used for this drawer, otherwise GDScript (first selectable # item: we skip any category separator at the head of the list). var start_idx := 0 for oi in entry_of_index.size(): var li: int = entry_of_index[oi] if li >= 0: start_idx = oi if langs[li].ext == _last_script_ext: break lang_opt.select(start_idx) # The suggested name updates with the chosen language's extension, like the # "Path" field of Godot's native dialog when the language changes. var update_name := func() -> void: var entry: Dictionary = langs[entry_of_index[lang_opt.selected]] var is_custom: bool = entry.ext.is_empty() custom_row.visible = is_custom var ext: String = custom_edit.text.strip_edges().trim_prefix(".") if is_custom else entry.ext var suffix := ("." + ext) if not ext.is_empty() else "" name_edit.text = _unique_path(dir, L.t("nouveau_script"), suffix).get_file().get_basename() lang_opt.item_selected.connect(func(_i: int) -> void: update_name.call()) custom_edit.text_changed.connect(func(_t: String) -> void: update_name.call()) update_name.call() d.confirmed.connect(func() -> void: var entry: Dictionary = langs[entry_of_index[lang_opt.selected]] var is_custom: bool = entry.ext.is_empty() var ext: String = (custom_edit.text.strip_edges().trim_prefix(".").to_lower()) if is_custom else entry.ext var base_name := name_edit.text.strip_edges() if base_name.is_empty() or ext.is_empty() or not _valid_name(base_name + "." + ext): return if ext == "cs" and not _has_csharp_project(): _flash(L.t("Pas de solution C# dans ce projet : créez un premier script C# depuis le dock Fichiers de Godot (il générera le .csproj), puis réessayez ici"), true) return var fname := base_name + "." + ext var p := dir.path_join(fname) if FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p): _flash(L.t("« %s » existe déjà") % fname, true) return var f := FileAccess.open(p, FileAccess.WRITE) if f == null: _flash(L.t("Impossible de créer « %s »") % fname, true) return f.store_string(_script_template(ext, base_name)) f.close() _last_script_ext = ext _save_cfg() _pending_select = p _scan() if ext == "cs" and EditorInterface.get_resource_filesystem().has_method("scan_sources"): EditorInterface.get_resource_filesystem().call("scan_sources") ) d.visibility_changed.connect(func() -> void: if not d.visible: d.queue_free() ) EditorInterface.get_base_control().add_child(d) d.popup_centered(Vector2i(380, 170)) name_edit.grab_focus() name_edit.select_all() ## Initial content depending on the extension: GDScript, C# and shader have a template, the rest is empty. func _script_template(ext: String, base_name: String) -> String: match ext: "gd": return "extends Node\n\n\nfunc _ready() -> void:\n\tpass\n" "cs": var cls := "" for ch in base_name.to_pascal_case(): if (ch >= "a" and ch <= "z") or (ch >= "A" and ch <= "Z") or (ch >= "0" and ch <= "9") or ch == "_": cls += ch if cls.is_empty() or (cls[0] >= "0" and cls[0] <= "9"): cls = "_" + cls return "using Godot;\n\npublic partial class %s : Node\n{\n\tpublic override void _Ready()\n\t{\n\t}\n}\n" % cls "gdshader": return "shader_type canvas_item;\n\nvoid fragment() {\n}\n" "json": return "{}\n" "xml": return "\n" "md": return "# %s\n" % base_name return "" func _rename(path: String) -> void: var src := path.trim_suffix("/") _prompt_string(L.t("Renommer"), src.get_file(), func(n: String) -> void: if not _valid_name(n): return var dst := src.get_base_dir().path_join(n) if dst == src: return if FileAccess.file_exists(dst) or DirAccess.dir_exists_absolute(dst): _flash(L.t("« %s » existe déjà") % n, true) return EditorInterface.save_all_scenes() # here rather than before the dialog var is_dir := DirAccess.dir_exists_absolute(src) # BEFORE the rename if DirAccess.rename_absolute(src, dst) != OK: _flash(L.t("Échec du renommage"), true) return for ext in [".import", ".uid"]: if FileAccess.file_exists(src + ext): DirAccess.rename_absolute(src + ext, dst + ext) _rewrite_paths(src, dst) current_dir = _swap_prefix(current_dir, src, dst) for i in _history.size(): _history[i] = _swap_prefix(_history[i], src, dst) _update_references([[src, dst, is_dir]]) # ADDED _pending_select = dst _scan() _flash(L.t("Renommé en « %s »") % n) ) func _duplicate(paths: PackedStringArray) -> void: var last := "" for p in paths: var src := p.trim_suffix("/") var dir := src.get_base_dir() if DirAccess.dir_exists_absolute(src): var dst := _unique_path(dir, src.get_file() + L.t("_copie"), "") _copy_dir(src, dst) last = dst else: var dst := _unique_path(dir, src.get_file().get_basename() + L.t("_copie"), "." + src.get_extension()) _copy_file(src, dst) last = dst _pending_select = last _scan() _flash(L.t("%d élément(s) dupliqué(s)") % paths.size() if paths.size() > 1 else L.t("Élément dupliqué")) func _copy_dir(src: String, dst: String) -> void: DirAccess.make_dir_recursive_absolute(dst) for f in DirAccess.get_files_at(src): if f.get_extension() == "import" or f.get_extension() == "uid": continue _copy_file(src.path_join(f), dst.path_join(f)) for d in DirAccess.get_directories_at(src): _copy_dir(src.path_join(d), dst.path_join(d)) func _copy_file(src: String, dst: String) -> void: DirAccess.copy_absolute(src, dst) # A .tscn/.tres embeds its UID in the header: we remove it from the copy to # avoid a duplicate UID (Godot assigns a new one). if dst.get_extension().to_lower() in ["tscn", "tres"]: var text := FileAccess.get_file_as_string(dst) var nl := text.find("\n") var head := text.substr(0, nl) if nl >= 0 else text var a := head.find(" uid=\"") if a >= 0: var b := head.find("\"", a + 6) if b > a: var f := FileAccess.open(dst, FileAccess.WRITE) if f: f.store_string(head.substr(0, a) + head.substr(b + 1) + text.substr(head.length())) f.close() func _confirm_delete(paths: PackedStringArray) -> void: var names: PackedStringArray = [] for p in paths: names.append(p.trim_suffix("/").get_file()) var txt := L.t("Envoyer à la corbeille :\n") + "\n".join(names.slice(0, 8)) if names.size() > 8: txt += "\n... (+%d)" % (names.size() - 8) _confirm(L.t("Supprimer"), txt, func() -> void: var ok_count := 0 for p in paths: var src := p.trim_suffix("/") var err := OS.move_to_trash(ProjectSettings.globalize_path(src)) if err != OK: _flash(L.t("Impossible de mettre « %s » à la corbeille") % src.get_file(), true) continue for ext in [".import", ".uid"]: if FileAccess.file_exists(src + ext): OS.move_to_trash(ProjectSettings.globalize_path(src + ext)) _forget_path(src) ok_count += 1 _save_cfg() _scan() if ok_count > 0: _flash(L.t("%d élément(s) envoyé(s) à la corbeille") % ok_count if ok_count > 1 else L.t("Envoyé à la corbeille")) ) func _forget_path(src: String) -> void: var fav := Array(_favorites) var rec := Array(_recents) fav = fav.filter(func(p) -> bool: return not _is_under(str(p), src)) rec = rec.filter(func(p) -> bool: return not _is_under(str(p), src)) _favorites = PackedStringArray(fav) _push_favorites() _recents = PackedStringArray(rec) _hidden = PackedStringArray(Array(_hidden).filter(func(p) -> bool: return not _is_under(str(p), src))) for k in _sets.keys(): var arr: Array = _sets[k] _sets[k] = arr.filter(func(p) -> bool: return not _is_under(str(p), src)) func _is_under(p: String, src: String) -> bool: var sp := p.trim_suffix("/") return sp == src or sp.begins_with(src + "/") const REF_EXTS := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "godot", "cfg"] func _collect_text_files(dir: String, out: Array) -> void: for d in DirAccess.get_directories_at(dir): if d.begins_with(".") or d == "addons": continue _collect_text_files(dir.path_join(d), out) for f in DirAccess.get_files_at(dir): if f.get_extension().to_lower() in REF_EXTS: out.append(dir.path_join(f)) # moves: Array of [src, dst, is_dir] func _update_references(moves: Array) -> void: if moves.is_empty(): return var files: Array = [] _collect_text_files("res://", files) var changed_files := 0 for path in files: var f := FileAccess.open(path, FileAccess.READ) if f == null: continue var text := f.get_as_text() f.close() var new_text := text for m in moves: var src: String = m[0] var dst: String = m[1] # Exact path in quotes (avoids touching "x.tres2", etc.) new_text = new_text.replace('"' + src + '"', '"' + dst + '"') if m[2]: # folder: everything that starts with src + "/" new_text = new_text.replace('"' + src + "/", '"' + dst + "/") if new_text != text: var w := FileAccess.open(path, FileAccess.WRITE) if w: w.store_string(new_text) w.close() changed_files += 1 if changed_files > 0: # Reload the open scenes, otherwise the editor may rewrite the old paths for scene in EditorInterface.get_open_scenes(): EditorInterface.reload_scene_from_path(scene) func _swap_prefix(p: String, src: String, dst: String) -> String: var sp := p.trim_suffix("/") var slash := "/" if p.ends_with("/") else "" if sp == src: return dst + slash if sp.begins_with(src + "/"): return dst + p.substr(src.length()) return p func _rewrite_paths(src: String, dst: String) -> void: for i in _favorites.size(): _favorites[i] = _swap_prefix(_favorites[i], src, dst) _push_favorites() for i in _recents.size(): _recents[i] = _swap_prefix(_recents[i], src, dst) for i in _hidden.size(): _hidden[i] = _swap_prefix(_hidden[i], src, dst) _tree_dirty = true for k in _sets.keys(): var arr: Array = _sets[k] for i in arr.size(): arr[i] = _swap_prefix(str(arr[i]), src, dst) var colors: Dictionary = _colors() var nc := {} var changed := false for k in colors.keys(): var nk := _swap_prefix(str(k), src, dst) changed = changed or nk != str(k) nc[nk] = colors[k] if changed: ProjectSettings.set_setting("file_customization/folder_colors", nc) ProjectSettings.save() _save_cfg() # ---------- Dialogs ---------- func _prompt_string(title: String, val: String, on_ok: Callable) -> void: var d := ConfirmationDialog.new() d.title = title d.ok_button_text = L.t("Valider") d.cancel_button_text = L.t("Annuler") var le := LineEdit.new() le.text = val le.custom_minimum_size.x = 320 d.add_child(le) d.register_text_enter(le) d.confirmed.connect(func() -> void: on_ok.call(le.text.strip_edges())) d.visibility_changed.connect(func() -> void: if not d.visible: d.queue_free() ) EditorInterface.get_base_control().add_child(d) d.popup_centered(Vector2i(360, 100)) le.grab_focus() le.select(0, val.get_basename().length() if val.contains(".") else val.length()) func _confirm(title: String, text: String, on_ok: Callable) -> void: var d := ConfirmationDialog.new() d.title = title d.dialog_text = text d.ok_button_text = L.t("Confirmer") d.cancel_button_text = L.t("Annuler") d.confirmed.connect(func() -> void: on_ok.call()) d.visibility_changed.connect(func() -> void: if not d.visible: d.queue_free() ) EditorInterface.get_base_control().add_child(d) d.popup_centered() # ---------- Favorites / recents / config ---------- # Godot's FileSystem dock favorites are authoritative (EditorSettings.get_favorites / set_favorites). func _sync_favorites() -> void: var es := EditorInterface.get_editor_settings() if es == null or not es.has_method("get_favorites"): return var godot_favs := PackedStringArray() for p in es.get_favorites(): var n := str(p) n = "res:/" if n == "res://" else n.trim_suffix("/") if not godot_favs.has(n): godot_favs.append(n) if not _fav_migrated: # Once only: favorites created before this version are added to Godot's _fav_migrated = true var added := false for p in _favorites: if not godot_favs.has(p) and (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)): godot_favs.append(p) added = true _favorites = godot_favs if added: _push_favorites() _save_cfg() return _favorites = godot_favs func _push_favorites() -> void: var es := EditorInterface.get_editor_settings() if es == null or not es.has_method("set_favorites"): return var out := PackedStringArray() for p in _favorites: if p == "res:/": out.append("res://") elif DirAccess.dir_exists_absolute(p): out.append(p + "/") else: out.append(p) es.set_favorites(out) func _is_fav(path: String) -> bool: return _favorites.has(path.trim_suffix("/")) func _toggle_favorite(path: String) -> void: _sync_favorites() var p := path.trim_suffix("/") if _favorites.has(p): var arr := Array(_favorites) arr.erase(p) _favorites = PackedStringArray(arr) else: _favorites.append(p) _push_favorites() _save_cfg() func _push_recent(path: String) -> void: var arr := Array(_recents) arr.erase(path) arr.push_front(path) if arr.size() > 20: arr.resize(20) _recents = PackedStringArray(arr) _save_cfg() func _load_cfg() -> void: var c := ConfigFile.new() if c.load(CFG_PATH) == OK: current_dir = c.get_value("state", "current_dir", "res://") if int(c.get_value("state", "cfg_version", 1)) >= CFG_VERSION: _height_ratio = c.get_value("state", "height_ratio", HEIGHT_RATIO) _fav_migrated = bool(c.get_value("state", "fav_migrated", false)) pinned = c.get_value("state", "pinned", false) _view_list = c.get_value("state", "view_list", false) _details_visible = c.get_value("state", "details_visible", true) _favorites = c.get_value("state", "favorites", PackedStringArray()) _recents = c.get_value("state", "recents", PackedStringArray()) _sets = c.get_value("state", "sets", {}) _zoom_grid = float(c.get_value("state", "zoom", 80.0)) _zoom_list = float(c.get_value("state", "zoom_list", 22.0)) _details_width = maxf(float(c.get_value("state", "details_width", 190.0)), DETAILS_MIN_WIDTH) _hidden = c.get_value("state", "hidden", PackedStringArray()) _show_hidden = bool(c.get_value("state", "show_hidden", false)) _search_all = bool(c.get_value("state", "search_all", true)) _last_script_ext = str(c.get_value("state", "script_ext", "gd")) _history = PackedStringArray([current_dir]) func _save_cfg() -> void: # Deferred write: avoids writing the file at every zoom step / navigation _save_dirty = true if _save_timer != null and _save_timer.is_inside_tree(): _save_timer.start() func _flush_cfg() -> void: if not _save_dirty: return _save_dirty = false var c := ConfigFile.new() c.set_value("state", "current_dir", current_dir) c.set_value("state", "cfg_version", CFG_VERSION) c.set_value("state", "fav_migrated", _fav_migrated) c.set_value("state", "height_ratio", _height_ratio) c.set_value("state", "pinned", pinned) c.set_value("state", "view_list", _view_list) c.set_value("state", "details_visible", _details_visible) c.set_value("state", "favorites", _favorites) c.set_value("state", "recents", _recents) c.set_value("state", "sets", _sets) c.set_value("state", "zoom", _zoom_grid) c.set_value("state", "zoom_list", _zoom_list) c.set_value("state", "details_width", _details_width) c.set_value("state", "hidden", _hidden) c.set_value("state", "show_hidden", _show_hidden) c.set_value("state", "search_all", _search_all) c.set_value("state", "script_ext", _last_script_ext) c.save(CFG_PATH)