"""Asset 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:
```text
+----------------------------------------------------------+
| [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, Pointer, PointerEvent, PointerFilter, PointerPhase, ScrollEvent, Signal
from simvx.core.descriptors import Property
from simvx.core.ui.theme import get_theme
log = logging.getLogger(__name__)
__all__ = ["AssetBrowserPanel"]
# -- 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 / 16.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))
# ============================================================================
# AssetBrowserPanel
# ============================================================================
[docs]
class AssetBrowserPanel(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 State instance (optional).
root_path: Explicit assets directory. When *None*, derived from
``editor_state.project_path / "assets"`` or cwd.
"""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
resource_selected = Signal()
size_x = Property(600.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(400.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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
# 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 on_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)
self._scroll_y = min(self._scroll_y, self._max_scroll())
[docs]
def set_type_filter(self, type_name: str):
"""Set the type filter by name (e.g. 'Textures', 'Audio', 'All').
Raises ``ValueError`` naming the valid filters when ``type_name`` is not one of them.
"""
if type_name not in _TYPE_NAMES:
raise ValueError(f"unknown asset type filter {type_name!r}; expected one of {', '.join(_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()
[docs]
@property
def type_filter(self) -> str:
return _TYPE_NAMES[self._type_index]
[docs]
@property
def filtered_entries(self) -> list[_AssetEntry]:
return self._filtered
[docs]
@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 on_draw(self, renderer):
t = get_theme()
gx, gy, gw, gh = self._local_rect()
renderer.rect((gx, gy), (gw, gh), fill=t.bg_dark)
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.rect((x, y), (w, _HEADER_HEIGHT), fill=t.bg_darker)
# Type filter label (acts as a visual dropdown indicator)
type_label = f"[{_TYPE_NAMES[self._type_index]}]"
renderer.text(type_label, (x + _PADDING, y + 8), fill=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.text(self._search_text, (search_x, y + 8), fill=t.text, scale=_FONT_SCALE)
else:
renderer.text("Search...", (search_x, y + 8), fill=t.text_dim, scale=_FONT_SCALE)
# Separator
renderer.rect((x, y + _HEADER_HEIGHT - 1), (w, 1), fill=t.border)
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.text(msg, (x + (w - tw) / 2, y + h / 2 - 7), fill=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.rect((cx - 2, cy - 2), (_CELL_WIDTH + 4, _CELL_HEIGHT + 4), fill=t.selection_bg)
# Icon background
icon_x = cx + (_CELL_WIDTH - _ICON_SIZE) / 2
renderer.rect((icon_x, cy), (_ICON_SIZE, _ICON_SIZE), fill=(0.15, 0.15, 0.15, 1.0))
# Icon label
iw = renderer.text_width(entry.icon, _FONT_SCALE)
renderer.text(
entry.icon,
(icon_x + (_ICON_SIZE - iw) / 2, cy + _ICON_SIZE / 2 - 6),
fill=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.text(name, (cx + (_CELL_WIDTH - nw) / 2, cy + _ICON_SIZE + 6), fill=t.text, scale=_FONT_SCALE)
renderer.pop_clip()
# ====================================================================
# Input handling
# ====================================================================
def _max_scroll(self) -> float:
"""How far the grid scrolls before its last row sits at the bottom of the panel."""
_, _, gw, gh = self._local_rect()
cols = max(1, int((gw - _PADDING) / (_CELL_WIDTH + _PADDING)))
rows = -(-len(self._filtered) // cols)
content_h = _PADDING + rows * (_CELL_HEIGHT + _PADDING)
return max(0.0, content_h - (gh - _HEADER_HEIGHT))
# ====================================================================
# 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))