Source code for simvx.core.ui.file_browser

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