Files
agame/addons/asset_drawer/drawer.gd
T
2026-10-03 06:39:17 +08:00

4988 lines
178 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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)