Source code for simvx.editor.panels.resource_browser

"""Resource Browser Panel -- Grid view of project assets with thumbnails and filtering.

Displays files from the project's ``assets/`` directory in a grid layout
with type-based icons, search filtering, and type category filtering.
Double-clicking or pressing Enter on an asset emits ``resource_selected``
for use by the inspector or viewport.

Layout:
    +----------------------------------------------------------+
    | [All v]  [__search___________________________]           |
    |----------------------------------------------------------+
    | +------+  +------+  +------+  +------+  +------+        |
    | | icon |  | icon |  | icon |  | icon |  | icon |        |
    | | name |  | name |  | name |  | name |  | name |        |
    | +------+  +------+  +------+  +------+  +------+        |
    | +------+  +------+  +------+  +------+  +------+        |
    | | icon |  | icon |  | icon |  | icon |  | icon |        |
    | | name |  | name |  | name |  | name |  | name |        |
    | +------+  +------+  +------+  +------+  +------+        |
    +----------------------------------------------------------+
"""

import logging
from pathlib import Path

from simvx.core import Control, Signal, Vec2
from simvx.core.ui.theme import get_theme

log = logging.getLogger(__name__)

__all__ = ["ResourceBrowserPanel"]

# -- Layout constants --
_HEADER_HEIGHT = 30.0
_CELL_WIDTH = 80.0
_CELL_HEIGHT = 90.0
_ICON_SIZE = 48.0
_PADDING = 6.0
_FONT_SCALE = 11.0 / 14.0

# -- File type categories and their extensions --
_TYPE_FILTERS: dict[str, set[str]] = {
    "All": set(),
    "Textures": {".png", ".jpg", ".jpeg", ".bmp", ".tga", ".webp", ".hdr"},
    "Audio": {".wav", ".ogg", ".mp3", ".flac"},
    "Models": {".obj", ".gltf", ".glb", ".fbx"},
    "Fonts": {".ttf", ".otf", ".woff", ".woff2"},
    "Animations": {".anim", ".json"},
    "Scripts": {".py"},
    "Shaders": {".glsl", ".vert", ".frag", ".comp", ".spv"},
}

_TYPE_NAMES = list(_TYPE_FILTERS.keys())

# Extension-to-icon glyph (single-char labels for the grid thumbnails)
_EXT_ICONS: dict[str, str] = {
    ".png": "IMG", ".jpg": "IMG", ".jpeg": "IMG", ".bmp": "IMG",
    ".tga": "IMG", ".webp": "IMG", ".hdr": "HDR",
    ".wav": "SND", ".ogg": "SND", ".mp3": "SND", ".flac": "SND",
    ".obj": "3D", ".gltf": "3D", ".glb": "3D", ".fbx": "3D",
    ".ttf": "Aa", ".otf": "Aa", ".woff": "Aa", ".woff2": "Aa",
    ".anim": "ANI", ".json": "JSN",
    ".py": "PY",
    ".glsl": "SH", ".vert": "SH", ".frag": "SH", ".comp": "SH", ".spv": "SH",
}

_ICON_COLOURS: dict[str, tuple[float, float, float, float]] = {
    "IMG": (0.3, 0.7, 0.4, 1.0),
    "SND": (0.7, 0.5, 0.2, 1.0),
    "3D": (0.4, 0.5, 0.8, 1.0),
    "Aa": (0.8, 0.6, 0.3, 1.0),
    "ANI": (0.6, 0.3, 0.7, 1.0),
    "JSN": (0.5, 0.5, 0.5, 1.0),
    "PY": (0.3, 0.6, 0.8, 1.0),
    "SH": (0.8, 0.4, 0.4, 1.0),
    "HDR": (0.3, 0.7, 0.4, 1.0),
}

_HIDDEN_DIRS = {"__pycache__", ".git", ".svn", "node_modules", ".mypy_cache", ".ruff_cache"}

# ============================================================================
# Asset entry
# ============================================================================

class _AssetEntry:
    """Lightweight data holder for a scanned asset file."""

    __slots__ = ("path", "name", "suffix", "icon", "icon_colour")

    def __init__(self, path: Path):
        self.path = path
        self.name = path.name
        self.suffix = path.suffix.lower()
        self.icon = _EXT_ICONS.get(self.suffix, "?")
        self.icon_colour = _ICON_COLOURS.get(self.icon, (0.5, 0.5, 0.5, 1.0))

# ============================================================================
# ResourceBrowserPanel
# ============================================================================

[docs] class ResourceBrowserPanel(Control): """Grid view of project assets with thumbnails and filtering. Scans the project's ``assets/`` directory (or a custom root) and displays files in a grid with type icons. A filter dropdown selects a category (Textures, Audio, Models, etc.) and a search field narrows by name. Signals: resource_selected(path: str) -- emitted on double-click / Enter. Args: editor_state: The central EditorState instance (optional). root_path: Explicit assets directory. When *None*, derived from ``editor_state.project_path / "assets"`` or cwd. """ resource_selected = Signal() def __init__(self, editor_state=None, root_path: Path | str | None = None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = get_theme().bg_dark self.size = Vec2(600, 400) # Assets root self._root: Path | None = Path(root_path) if root_path else None # All scanned entries and filtered view self._all_entries: list[_AssetEntry] = [] self._filtered: list[_AssetEntry] = [] # Filter state self._type_index: int = 0 # index into _TYPE_NAMES self._search_text: str = "" # Selection / scroll self._selected_index: int = -1 self._scroll_y: float = 0.0 # ==================================================================== # Lifecycle # ====================================================================
[docs] def ready(self): self._resolve_root() self._scan_assets()
# ==================================================================== # Root resolution # ==================================================================== def _resolve_root(self): """Determine the assets directory from explicit path or editor state.""" if self._root and self._root.is_dir(): return if self.state and hasattr(self.state, "project_path") and self.state.project_path: candidate = Path(self.state.project_path) / "assets" if candidate.is_dir(): self._root = candidate return # Fallback: cwd/assets cwd_assets = Path.cwd() / "assets" if cwd_assets.is_dir(): self._root = cwd_assets # ==================================================================== # Scanning # ==================================================================== def _scan_assets(self): """Scan the assets directory recursively for files.""" self._all_entries.clear() if not self._root or not self._root.is_dir(): self._apply_filter() return try: for p in sorted(self._root.rglob("*")): if not p.is_file(): continue # Skip hidden directories if any(part in _HIDDEN_DIRS for part in p.parts): continue self._all_entries.append(_AssetEntry(p)) except OSError: log.warning("Failed to scan assets directory: %s", self._root) self._apply_filter()
[docs] def refresh(self): """Re-scan and re-filter the assets directory.""" self._resolve_root() self._scan_assets()
# ==================================================================== # Filtering # ==================================================================== def _apply_filter(self): """Rebuild ``_filtered`` from current type and search filters.""" type_name = _TYPE_NAMES[self._type_index] allowed_exts = _TYPE_FILTERS[type_name] search = self._search_text.lower().strip() result: list[_AssetEntry] = [] for entry in self._all_entries: # Type filter if allowed_exts and entry.suffix not in allowed_exts: continue # Search filter if search and search not in entry.name.lower(): continue result.append(entry) self._filtered = result self._selected_index = min(self._selected_index, len(self._filtered) - 1)
[docs] def set_type_filter(self, type_name: str): """Set the type filter by name (e.g. 'Textures', 'Audio', 'All').""" if type_name in _TYPE_NAMES: self._type_index = _TYPE_NAMES.index(type_name) self._apply_filter()
@property def type_filter(self) -> str: return _TYPE_NAMES[self._type_index] @property def filtered_entries(self) -> list[_AssetEntry]: return self._filtered @property def selected_entry(self) -> _AssetEntry | None: if 0 <= self._selected_index < len(self._filtered): return self._filtered[self._selected_index] return None # ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): t = get_theme() gx, gy, gw, gh = self.get_global_rect() renderer.draw_rect((gx, gy), (gw, gh), colour=t.bg_dark, filled=True) renderer.push_clip(gx, gy, gw, gh) self._draw_header(renderer, gx, gy, gw) self._draw_grid(renderer, gx, gy + _HEADER_HEIGHT, gw, gh - _HEADER_HEIGHT) renderer.pop_clip()
def _draw_header(self, renderer, x, y, w): """Draw the filter bar: type dropdown label + search text.""" t = get_theme() renderer.draw_rect((x, y), (w, _HEADER_HEIGHT), colour=t.bg_darker, filled=True) # Type filter label (acts as a visual dropdown indicator) type_label = f"[{_TYPE_NAMES[self._type_index]}]" renderer.draw_text(type_label, (x + _PADDING, y + 8), colour=t.accent, scale=_FONT_SCALE) # Search text tw = renderer.text_width(type_label, _FONT_SCALE) search_x = x + _PADDING + tw + 12 if self._search_text: renderer.draw_text(self._search_text, (search_x, y + 8), colour=t.text, scale=_FONT_SCALE) else: renderer.draw_text("Search...", (search_x, y + 8), colour=t.text_dim, scale=_FONT_SCALE) # Separator renderer.draw_rect((x, y + _HEADER_HEIGHT - 1), (w, 1), colour=t.border, filled=True) def _draw_grid(self, renderer, x, y, w, h): """Draw the asset grid.""" t = get_theme() if not self._filtered: msg = "No assets found" if self._root else "No assets/ directory" tw = renderer.text_width(msg, _FONT_SCALE) renderer.draw_text(msg, (x + (w - tw) / 2, y + h / 2 - 7), colour=t.text_dim, scale=_FONT_SCALE) return renderer.push_clip(x, y, w, h) cols = max(1, int((w - _PADDING) / (_CELL_WIDTH + _PADDING))) cell_y = y + _PADDING - self._scroll_y for i, entry in enumerate(self._filtered): col = i % cols row = i // cols cx = x + _PADDING + col * (_CELL_WIDTH + _PADDING) cy = cell_y + row * (_CELL_HEIGHT + _PADDING) # Skip cells outside visible area if cy + _CELL_HEIGHT < y: continue if cy > y + h: break # Selection highlight if i == self._selected_index: renderer.draw_rect( (cx - 2, cy - 2), (_CELL_WIDTH + 4, _CELL_HEIGHT + 4), colour=t.selection_bg, filled=True ) # Icon background icon_x = cx + (_CELL_WIDTH - _ICON_SIZE) / 2 renderer.draw_rect((icon_x, cy), (_ICON_SIZE, _ICON_SIZE), colour=(0.15, 0.15, 0.15, 1.0), filled=True) # Icon label iw = renderer.text_width(entry.icon, _FONT_SCALE) renderer.draw_text( entry.icon, (icon_x + (_ICON_SIZE - iw) / 2, cy + _ICON_SIZE / 2 - 6), colour=entry.icon_colour, scale=_FONT_SCALE, ) # File name (truncated) name = entry.name max_chars = int(_CELL_WIDTH / 6) if len(name) > max_chars: name = name[: max_chars - 2] + ".." nw = renderer.text_width(name, _FONT_SCALE) renderer.draw_text( name, (cx + (_CELL_WIDTH - nw) / 2, cy + _ICON_SIZE + 6), colour=t.text, scale=_FONT_SCALE, ) renderer.pop_clip() # ==================================================================== # Input handling # ==================================================================== def _on_gui_input(self, event): gx, gy, gw, gh = self.get_global_rect() if not hasattr(event, "position"): return ex, ey = event.position if not (gx <= ex <= gx + gw and gy <= ey <= gy + gh): return # Grid click grid_top = gy + _HEADER_HEIGHT + _PADDING if ey >= grid_top and hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1: cols = max(1, int((gw - _PADDING) / (_CELL_WIDTH + _PADDING))) local_x = ex - gx - _PADDING local_y = ey - grid_top + self._scroll_y col = int(local_x / (_CELL_WIDTH + _PADDING)) row = int(local_y / (_CELL_HEIGHT + _PADDING)) idx = row * cols + col if 0 <= col < cols and 0 <= idx < len(self._filtered): if self._selected_index == idx and hasattr(event, "double_click") and event.double_click: # Double click emits selection self.resource_selected.emit(str(self._filtered[idx].path)) self._selected_index = idx # Header type filter click (cycle through types) if ey < gy + _HEADER_HEIGHT and hasattr(event, "pressed") and event.pressed: if getattr(event, "button", 0) == 1: # Click on type label area cycles filter type_label = f"[{_TYPE_NAMES[self._type_index]}]" tw = _PADDING + len(type_label) * 7 # rough width estimate if ex - gx < tw + 20: self._type_index = (self._type_index + 1) % len(_TYPE_NAMES) self._apply_filter() # Scroll if hasattr(event, "delta") and ey >= grid_top: _, dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta) self._scroll_y = max(0.0, self._scroll_y - dy * 20.0) # ==================================================================== # Public API for selection # ====================================================================
[docs] def select_by_path(self, path: str | Path) -> bool: """Select an asset by its file path. Returns True if found.""" target = Path(path) for i, entry in enumerate(self._filtered): if entry.path == target: self._selected_index = i return True return False
[docs] def activate_selected(self): """Emit ``resource_selected`` for the current selection.""" entry = self.selected_entry if entry: self.resource_selected.emit(str(entry.path))