"""Project file browser -- unified tree view with filter, context menu, and drag support."""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from ..descriptors import Signal
from ..math.types import Vec2
from .core import Control
from .menu import MenuItem, PopupMenu
from .theme import get_theme
from .tree import TreeItem, TreeView
from .widgets import TextEdit
log = logging.getLogger(__name__)
__all__ = ["FileBrowserPanel"]
# Layout constants
_HEADER_H = 28.0
_FILTER_H = 26.0
_PAD = 6.0
_DOUBLE_CLICK_TIME = 0.4
# Hidden entries
_HIDDEN_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache", ".ruff_cache", ".egg-info"}
# File type icons (ASCII — emoji codepoints don't render in monospace bitmap fonts)
_ICON_DIR = "[D]"
_ICON_SCENE = "[S]"
_ICON_SCRIPT = "[P]"
_ICON_TEXTURE = "[I]"
_ICON_MODEL = "[M]"
_ICON_AUDIO = "[A]"
_ICON_SHADER = "[H]"
_ICON_CONFIG = "[C]"
_ICON_TEXT = "[T]"
_ICON_GENERIC = "[.]"
# Extension to icon mapping
_EXT_ICONS: dict[str, str] = {
".py": _ICON_SCRIPT, ".simvx": _ICON_SCENE, ".json": _ICON_SCENE,
".png": _ICON_TEXTURE, ".jpg": _ICON_TEXTURE, ".jpeg": _ICON_TEXTURE,
".bmp": _ICON_TEXTURE, ".tga": _ICON_TEXTURE,
".obj": _ICON_MODEL, ".gltf": _ICON_MODEL, ".glb": _ICON_MODEL, ".fbx": _ICON_MODEL,
".wav": _ICON_AUDIO, ".ogg": _ICON_AUDIO, ".mp3": _ICON_AUDIO, ".flac": _ICON_AUDIO,
".glsl": _ICON_SHADER, ".vert": _ICON_SHADER, ".frag": _ICON_SHADER, ".spv": _ICON_SHADER,
".toml": _ICON_CONFIG, ".yaml": _ICON_CONFIG, ".yml": _ICON_CONFIG, ".ini": _ICON_CONFIG,
".md": _ICON_TEXT, ".txt": _ICON_TEXT, ".rst": _ICON_TEXT,
}
# Draggable asset extensions
_DRAG_EXTENSIONS = {
".json", ".simvx", ".png", ".jpg", ".jpeg", ".bmp", ".tga",
".obj", ".gltf", ".glb", ".fbx", ".wav", ".ogg", ".mp3", ".flac",
".glsl", ".vert", ".frag",
}
def _icon_for_path(path: Path) -> str:
"""Return icon string based on file type."""
if path.is_dir():
return _ICON_DIR
return _EXT_ICONS.get(path.suffix.lower(), _ICON_GENERIC)
[docs]
class FileBrowserPanel(Control):
"""Project file browser with tree view, filter, context menu, drag, and rename.
Combines editor and IDE file browser features into a single reusable panel.
Parameters:
title: Header title text (default "Files")
show_icons: Show file-type Unicode icons (default True)
drag_enabled: Enable drag data for assets (default False)
show_scene_actions: Show "New Script"/"New Scene" in context menu (default False)
Signals:
file_selected(path: str) -- single-click selection
file_activated(path: str) -- double-click open
file_opened(path: str) -- alias for file_activated (editor compat)
file_created(path: str) -- after file/folder creation
file_deleted(path: str) -- after deletion
file_renamed(old: str, new: str) -- after rename
"""
def __init__(self, *, title: str = "Files", show_icons: bool = True, drag_enabled: bool = False,
show_scene_actions: bool = False, **kwargs):
super().__init__(**kwargs)
self.name = title
self._title = title
self._show_icons = show_icons
self._drag_enabled = drag_enabled
self._show_scene_actions = show_scene_actions
self._project_root: Path | None = None
self._selected_path: str = ""
theme = get_theme()
self.bg_colour = theme.panel_bg
self.size = Vec2(280, 400)
# Signals
self.file_selected = Signal()
self.file_activated = Signal()
self.file_opened = Signal()
self.file_created = Signal()
self.file_deleted = Signal()
self.file_renamed = Signal()
# Filter bar
self._filter = TextEdit(placeholder="Filter files...")
self._filter.size = Vec2(200, _FILTER_H)
self._filter.font_size = 13.0
self._filter.bg_colour = theme.bg_input
self._filter.border_colour = theme.border
self._filter.text_changed.connect(self._on_filter_changed)
# Tree view
self._tree_view = TreeView()
self._tree_view.bg_colour = theme.panel_bg
self._tree_view.row_height = 22.0
self._tree_view.font_size = 13.0
self._tree_view.item_selected.connect(self._on_item_selected)
# Context menu -- built dynamically
self._context_menu: PopupMenu | None = None
self._context_path: str = ""
self._context_is_dir: bool = False
# Double-click tracking
self._last_click_item: TreeItem | None = None
self._click_timer: float = 0.0
# Auto-refresh
self._refresh_timer: float = 0.0
self._refresh_interval: float = 3.0
# Refresh button hover
self._refresh_btn_hovered = False
# Rename overlay
self._rename_edit = TextEdit(name="RenameEdit")
self._rename_edit.size = Vec2(200, 22)
self._rename_edit.font_size = 13.0
self._rename_edit.visible = False
self._rename_edit.text_submitted.connect(self._on_rename_submitted)
self._rename_path: str = ""
self.add_child(self._rename_edit)
# ------------------------------------------------------------------ public
[docs]
def set_root(self, path: str | Path):
"""Set the project root directory and rebuild the tree."""
self._project_root = Path(path).resolve()
self._rebuild_tree()
# Editor-compat alias
set_project_root = set_root
[docs]
def get_root(self) -> str:
"""Return the current project root path as a string."""
return str(self._project_root) if self._project_root else ""
# Editor-compat alias
get_project_root = get_root
[docs]
def get_selected_path(self) -> str:
"""Return the path of the currently selected item."""
return self._selected_path
[docs]
def refresh(self):
"""Rescan the project directory and rebuild the tree."""
self._rebuild_tree()
# -------------------------------------------------------------- tree build
def _effective_root(self) -> Path | None:
"""Return the project root, falling back to CWD if unset."""
return self._project_root or Path.cwd()
def _rebuild_tree(self):
root = self._effective_root()
if not root or not root.is_dir():
self._tree_view.root = None
return
expanded = self._save_expansion_state()
filter_text = str(self._filter.text).lower().strip() if self._filter.text else ""
root_item = self._scan_directory(root, filter_text)
if expanded:
self._restore_expansion_state(root_item, expanded)
else:
root_item.expanded = True
self._tree_view.root = root_item
def _scan_directory(self, dir_path: Path, filter_text: str = "", _root: bool = True) -> TreeItem:
"""Recursively scan a directory into a TreeItem hierarchy."""
if self._show_icons:
label = f"{_ICON_DIR} {dir_path.name}"
else:
label = dir_path.name + "/"
item = TreeItem(label, data={"path": str(dir_path), "is_dir": True}, expanded=False)
try:
entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
except PermissionError:
return item
for entry in entries:
name = entry.name
if name.startswith(".") or name in _HIDDEN_DIRS or name.endswith(".egg-info"):
continue
if entry.is_dir():
child = self._scan_directory(entry, filter_text, _root=False)
if filter_text and not child.children:
continue
item.add_child(child)
else:
if filter_text and filter_text not in name.lower():
continue
if self._show_icons:
icon = _icon_for_path(entry)
child_label = f"{icon} {name}"
else:
child_label = name
item.add_child(TreeItem(child_label, data={"path": str(entry), "is_dir": False}))
return item
def _save_expansion_state(self) -> set[str]:
expanded: set[str] = set()
root = self._tree_view.root
if root:
self._collect_expanded(root, expanded)
return expanded
def _collect_expanded(self, item: TreeItem, expanded: set[str]):
if item.data and item.data.get("is_dir") and item.expanded:
expanded.add(item.data["path"])
for child in item.children:
self._collect_expanded(child, expanded)
def _restore_expansion_state(self, item: TreeItem, expanded_paths: set[str]):
if item.data and item.data.get("is_dir"):
item.expanded = item.data["path"] in expanded_paths
for child in item.children:
self._restore_expansion_state(child, expanded_paths)
# ------------------------------------------------------------ interaction
def _on_item_selected(self, item: TreeItem):
"""Handle selection -- track double-click and emit signals."""
if not item.data:
return
path = item.data.get("path", "")
is_dir = item.data.get("is_dir", False)
self._selected_path = path
self.file_selected.emit(path)
is_double = item is self._last_click_item and self._click_timer < _DOUBLE_CLICK_TIME
self._last_click_item = item
self._click_timer = 0.0
if is_double:
if is_dir:
item.expanded = not item.expanded
self._tree_view._invalidate_flat_rows()
self._tree_view.queue_redraw()
else:
self.file_activated.emit(path)
self.file_opened.emit(path)
def _on_filter_changed(self, _text: str):
self._rebuild_tree()
def _on_gui_input(self, event):
px = event.position.x if hasattr(event.position, "x") else event.position[0]
py = event.position.y if hasattr(event.position, "y") else event.position[1]
# Refresh button
rx, ry, rw, rh = self._refresh_button_rect()
self._refresh_btn_hovered = rx <= px <= rx + rw and ry <= py <= ry + rh
if event.button == 1 and event.pressed and self._refresh_btn_hovered:
self.refresh()
return
# Right-click context menu
if event.button == 3 and event.pressed:
_, gy, _, _ = self.get_global_rect()
if py >= gy + _HEADER_H + _FILTER_H + _PAD:
# Determine which item was right-clicked
root = self._effective_root()
path = str(root) if root else ""
is_dir = True
item = self._find_item_at_y(py)
if item and item.data:
path = item.data.get("path", path)
is_dir = item.data.get("is_dir", False)
self._context_path = path
self._context_is_dir = is_dir
self._show_context_menu(px, py)
return
# Forward to tree view (only in tree area)
_, gy, _, _ = self.get_global_rect()
if py >= gy + _HEADER_H + _FILTER_H + _PAD:
self._tree_view._on_gui_input(event)
# Forward to filter bar
elif py >= gy + _HEADER_H:
self._filter._on_gui_input(event)
def _find_item_at_y(self, y: float) -> TreeItem | None:
"""Find which tree item is at the given screen y coordinate."""
for item, _rx, ry, _depth in self._tree_view._row_map:
if ry <= y < ry + self._tree_view.row_height:
return item
return None
# ----------------------------------------------------------- context menu
def _show_context_menu(self, x: float, y: float):
"""Build and show the context menu at (x, y)."""
items: list[MenuItem] = []
items.append(MenuItem("New File", callback=self._create_file))
items.append(MenuItem("New Folder", callback=self._action_new_folder))
if self._show_scene_actions:
items.append(MenuItem("New Script", callback=self._action_new_script))
items.append(MenuItem("New Scene", callback=self._action_new_scene))
items.append(MenuItem(separator=True))
items.append(MenuItem("Rename", callback=self._action_rename))
items.append(MenuItem("Delete", callback=self._delete_item))
items.append(MenuItem(separator=True))
items.append(MenuItem("Refresh", callback=self.refresh))
self._context_menu = PopupMenu(items=items)
self._context_menu.show(x, y)
# ----------------------------------------------------------- drag support
[docs]
def get_drag_data(self, path: str | None = None) -> dict | None:
"""Create drag data for a file path (for drag-and-drop into the scene).
Returns a dict with keys: type, path, extension. Returns None if
the file type is not a recognized draggable asset or drag is disabled.
"""
if not self._drag_enabled:
return None
path = path or self._selected_path
if not path:
return None
ext = Path(path).suffix.lower()
if ext not in _DRAG_EXTENSIONS:
return None
return {"type": "file", "path": path, "extension": ext}
# ------------------------------------------------------------- ctx actions
def _selected_directory(self) -> Path | None:
if self._context_path:
p = Path(self._context_path)
return p if p.is_dir() else p.parent
if self._tree_view.selected and self._tree_view.selected.data:
p = Path(self._tree_view.selected.data["path"])
return p if p.is_dir() else p.parent
return self._effective_root()
def _create_file(self):
"""Create a new empty file in the context directory."""
parent = self._context_path if self._context_is_dir else str(Path(self._context_path).parent)
if not parent:
root = self._effective_root()
parent = str(root) if root else ""
if not parent:
return
name = "untitled"
suffix = ""
count = 1
while (Path(parent) / f"{name}{suffix}").exists():
suffix = f"_{count}"
count += 1
new_path = str(Path(parent) / f"{name}{suffix}")
try:
Path(new_path).write_text("", encoding="utf-8")
except OSError:
return
self._rebuild_tree()
self.file_created.emit(new_path)
self.file_activated.emit(new_path)
self.file_selected.emit(new_path)
def _action_new_folder(self):
parent = self._selected_directory()
if not parent:
return
target = parent / "New Folder"
n = 1
while target.exists():
n += 1
target = parent / f"New Folder {n}"
try:
target.mkdir(parents=False, exist_ok=True)
except OSError:
return
self._rebuild_tree()
def _create_folder(self):
"""Create a new folder in the context directory (IDE-compat entry point)."""
parent = self._context_path if self._context_is_dir else str(Path(self._context_path).parent)
if not parent:
root = self._effective_root()
parent = str(root) if root else ""
if not parent:
return
name = "new_folder"
suffix = ""
count = 1
while (Path(parent) / f"{name}{suffix}").exists():
suffix = f"_{count}"
count += 1
new_path = str(Path(parent) / f"{name}{suffix}")
try:
Path(new_path).mkdir(parents=True)
except OSError:
return
self._rebuild_tree()
def _action_new_script(self):
parent = self._selected_directory()
if not parent:
return
target = parent / "new_script.py"
n = 1
while target.exists():
n += 1
target = parent / f"new_script_{n}.py"
try:
target.write_text('"""New script."""\n\nfrom simvx.core import Node\n', encoding="utf-8")
except OSError:
return
self._rebuild_tree()
self.file_created.emit(str(target))
self.file_activated.emit(str(target))
def _action_new_scene(self):
parent = self._selected_directory()
if not parent:
return
target = parent / "new_scene.json"
n = 1
while target.exists():
n += 1
target = parent / f"new_scene_{n}.json"
try:
target.write_text(json.dumps({"type": "Node", "name": "Root", "children": []}, indent=2), encoding="utf-8")
except OSError:
return
self._rebuild_tree()
def _action_rename(self):
"""Show an inline text-edit overlay to rename the selected file/folder."""
# Prefer context path, fall back to tree selection
path_str = self._context_path
if not path_str and self._tree_view.selected and self._tree_view.selected.data:
path_str = self._tree_view.selected.data.get("path", "")
if not path_str:
return
self._start_rename_for(path_str)
def _start_rename(self):
"""Start inline rename of the context item (IDE-compat entry point)."""
self._cancel_rename()
if not self._context_path:
return
self._start_rename_for(self._context_path)
def _start_rename_for(self, path_str: str):
"""Show rename overlay for the given path."""
p = Path(path_str)
self._rename_path = path_str
self._rename_edit.text = p.name
self._rename_edit.cursor_pos = len(p.name)
# Position the edit over the selected tree item row
gx, gy, gw, _ = self.get_global_rect()
row_y = gy + _HEADER_H + _FILTER_H + 8
for item, _, iy, _ in self._tree_view._row_map:
if item is self._tree_view.selected:
row_y = iy
break
self._rename_edit.position = Vec2(30, row_y - gy)
self._rename_edit.size = Vec2(gw - 40, 22)
self._rename_edit.visible = True
if self._tree:
self._tree._set_focused_control(self._rename_edit)
def _on_rename_submitted(self, new_name: str):
"""Complete the rename when the user presses Enter."""
self._rename_edit.visible = False
new_name = new_name.strip()
if not new_name or not self._rename_path:
return
self.rename_file(self._rename_path, new_name)
self._rename_path = ""
def _finish_rename(self, new_name: str):
"""Complete the rename operation (IDE-compat entry point)."""
if not new_name or not self._rename_path:
self._cancel_rename()
return
old_path = Path(self._rename_path)
new_path = old_path.parent / new_name
if new_path.exists() or not old_path.exists():
self._cancel_rename()
return
try:
old_path.rename(new_path)
except OSError:
self._cancel_rename()
return
self._rebuild_tree()
self.file_renamed.emit(str(old_path), str(new_path))
self._cancel_rename()
def _cancel_rename(self):
"""Cancel inline rename and hide the overlay."""
self._rename_edit.visible = False
def _action_delete(self):
"""Delete selected file via tree selection."""
if not self._tree_view.selected or not self._tree_view.selected.data:
return
path = Path(self._tree_view.selected.data["path"])
root = self._effective_root()
if root and path == root:
return
self._do_delete(str(path))
def _delete_item(self):
"""Delete the context item (from context menu or programmatic call)."""
path = self._context_path
if not path:
return
self._do_delete(path)
def _do_delete(self, path: str):
"""Actually perform the deletion."""
try:
if Path(path).is_dir():
shutil.rmtree(path)
else:
Path(path).unlink()
except OSError:
return
self._selected_path = ""
self._rebuild_tree()
self.file_deleted.emit(path)
# File operations (public API)
[docs]
def create_folder(self, parent_path: str, name: str) -> str | None:
"""Create a new folder and refresh the tree. Returns the new path."""
new_path = Path(parent_path) / name
try:
new_path.mkdir(parents=True, exist_ok=True)
except OSError:
return None
self._rebuild_tree()
return str(new_path)
[docs]
def delete_file(self, path: str) -> bool:
"""Delete a file or directory. Returns True on success."""
p = Path(path)
try:
if p.is_file():
p.unlink()
elif p.is_dir():
shutil.rmtree(p)
else:
return False
except OSError:
return False
self._rebuild_tree()
return True
[docs]
def rename_file(self, old_path: str, new_name: str) -> str | None:
"""Rename a file or directory. Returns the new path, or None on failure."""
p = Path(old_path)
new_path = p.parent / new_name
if new_path.exists() or not p.exists():
return None
try:
p.rename(new_path)
except OSError:
return None
self._rebuild_tree()
self.file_renamed.emit(old_path, str(new_path))
return str(new_path)
# ---------------------------------------------------------------- process
[docs]
def process(self, dt: float):
self._click_timer += dt
self._refresh_timer += dt
if self._refresh_timer >= self._refresh_interval:
self._refresh_timer = 0.0
self._rebuild_tree()
# ------------------------------------------------------------- geometry
def _header_rect(self) -> tuple[float, float, float, float]:
x, y, w, _ = self.get_global_rect()
return (x, y, w, _HEADER_H)
def _refresh_button_rect(self) -> tuple[float, float, float, float]:
x, y, w, _ = self.get_global_rect()
bw, bh = 20.0, 18.0
return (x + w - bw - _PAD, y + (_HEADER_H - bh) / 2, bw, bh)
# ---------------------------------------------------------------- drawing
[docs]
def refresh_theme(self):
"""Re-apply theme colours after a theme change."""
theme = get_theme()
self.bg_colour = theme.panel_bg
self._tree_view.bg_colour = theme.panel_bg
self._filter.bg_colour = theme.bg_input
self._filter.border_colour = theme.border
[docs]
def draw(self, renderer):
theme = get_theme()
x, y, w, h = self.get_global_rect()
# Panel background
renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
# -- Header bar --
hx, hy, hw, hh = self._header_rect()
renderer.draw_filled_rect(hx, hy, hw, hh, theme.header_bg)
renderer.draw_line_coloured(hx, hy + hh, hx + hw, hy + hh, theme.border)
scale = 12.0 / 14.0
renderer.draw_text_coloured(self._title, hx + _PAD, hy + (hh - 12) / 2, scale, theme.text)
# Refresh button
rx, ry, rw, rh = self._refresh_button_rect()
renderer.draw_filled_rect(rx, ry, rw, rh, theme.btn_hover if self._refresh_btn_hovered else theme.btn_bg)
rs = 10.0 / 14.0
rtw = renderer.text_width("\u21bb", rs)
renderer.draw_text_coloured("\u21bb", rx + (rw - rtw) / 2, ry + (rh - 10) / 2, rs, theme.text)
# -- Filter bar --
filter_y = hy + hh + 2
self._filter.position = Vec2(hx + _PAD - x, filter_y - y)
self._filter.size = Vec2(hw - 2 * _PAD, _FILTER_H)
# -- Tree area --
tree_y = filter_y + _FILTER_H + 4
tw, th = w, max(0, h - (tree_y - y))
renderer.push_clip(x, tree_y, tw, th)
if self._tree_view.root:
self._tree_view.size = Vec2(tw, th)
self._tree_view._draw_visible_rows(renderer, x, tree_y, tw, th)
else:
msg = "Set a project root to browse files"
ms = 11.0 / 14.0
mw = renderer.text_width(msg, ms)
renderer.draw_text_coloured(msg, x + (tw - mw) / 2, tree_y + th / 2 - 6, ms, theme.text_dim)
renderer.pop_clip()