"""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()
[docs]
def set_search(self, text: str):
"""Set the search filter text."""
self._search_text = text
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))