"""Project file browser -- unified tree view with filter, context menu, and drag support."""
import json
import logging
import shutil
from pathlib import Path
from ..descriptors import Signal
from ..git_status import GitStatusProvider, Status
from ..math.types import Vec2
from ..properties import Colour
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"]
# Sublime/VSCode-style git status dot colours, keyed by Status enum.
# CLEAN intentionally omitted — clean files draw no dot.
_STATUS_DOT_COLOURS: dict[Status, tuple[float, float, float, float]] = {
Status.MODIFIED_UNSAVED: Colour.hex("#E5C07B"), # yellow
Status.SAVED_UNCOMMITTED: Colour.hex("#D19A66"), # orange
Status.COMMITTED_UNPUSHED: Colour.hex("#61AFEF"), # blue
Status.CONFLICTED: Colour.hex("#E06C75"), # red
Status.UNTRACKED: Colour.hex("#98C379"), # green
}
# Dot geometry — 6px diameter circle, inset 12px from the row's right edge.
_DOT_RADIUS = 3.0
_DOT_INSET = 12.0
# 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) -- raw UX event: tree row was double-clicked
(any file type). Subclasses listen here
to dispatch on extension.
file_opened(path: str) -- application-level event: a subclass has
decided this file should be opened in an
editor / tab. The base class never emits
this — subclasses opt in.
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,
git_status: GitStatusProvider | None = None,
**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._git_status = git_status
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()
[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 ""
[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)
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_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
# -- Header bar --
hx, hy, hw, hh = self._header_rect()
renderer.draw_rect((hx, hy), (hw, hh), colour=theme.header_bg, filled=True)
renderer.draw_line((hx, hy + hh), (hx + hw, hy + hh), colour=theme.border)
scale = 12.0 / 14.0
renderer.draw_text(self._title, (hx + _PAD, hy + (hh - 12) / 2), colour=theme.text, scale=scale)
# Refresh button
rx, ry, rw, rh = self._refresh_button_rect()
renderer.draw_rect(
(rx, ry), (rw, rh), colour=theme.btn_hover if self._refresh_btn_hovered else theme.btn_bg, filled=True
)
rs = 10.0 / 14.0
rtw = renderer.text_width("\u21bb", rs)
renderer.draw_text("\u21bb", (rx + (rw - rtw) / 2, ry + (rh - 10) / 2), colour=theme.text, scale=rs)
# -- 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)
self._draw_status_dots(renderer, x + tw)
else:
msg = "Set a project root to browse files"
ms = 11.0 / 14.0
mw = renderer.text_width(msg, ms)
renderer.draw_text(msg, (x + (tw - mw) / 2, tree_y + th / 2 - 6), colour=theme.text_dim, scale=ms)
renderer.pop_clip()
def _draw_status_dots(self, renderer, row_right: float) -> None:
"""Overlay a Sublime-style git-status dot on each visible file row.
Iterates the TreeView's hit-test row map (populated by the immediately
preceding ``_draw_visible_rows`` call) so the dot geometry tracks
whatever row layout the tree decided on. Directories never get a dot,
and ``Status.CLEAN`` rows draw nothing.
"""
if self._git_status is None:
return
row_height = self._tree_view.row_height
cx = row_right - _DOT_INSET
for item, _row_x, row_y, _depth in self._tree_view._row_map:
if not item.data or item.data.get("is_dir"):
continue
path = item.data.get("path")
if not path:
continue
status = self._git_status.status_for(path)
if status is Status.CLEAN:
continue
# Strict-raise on unknown enum values — programmer error, not a
# runtime condition we want to mask.
colour = _STATUS_DOT_COLOURS[status]
cy = row_y + row_height * 0.5
renderer.draw_circle((cx, cy), _DOT_RADIUS, colour=colour, filled=True)