Source code for simvx.core.ui.file_browser

"""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()