Source code for simvx.editor.node_catalogue

"""NodeCatalogue — Searchable popup for selecting a node type to add.

Displays all registered engine node types organised by category (2D, 3D, UI,
Physics, Audio, Animation, etc.) with fuzzy search filtering.  Emits
``node_selected(node_class)`` when the user picks a type.

Uses the same fuzzy scoring as :func:`simvx.editor.command_palette.fuzzy_score`.
"""

from simvx.core import Node, Node2D, Node3D, Signal, Vec2
from simvx.core.ui.core import Control, UIInputEvent

from .command_palette import fuzzy_score

# ---------------------------------------------------------------------------
# Category definitions
# ---------------------------------------------------------------------------

# Each entry: (category_name, base_class_or_None, explicit_class_names_set)
# When base_class is given, any Node._registry entry whose class is a subclass
# (directly or indirectly) is placed in that category — *unless* a more specific
# category claims it first.
#
# Ordering matters: earlier categories take priority.

_CATEGORY_RULES: list[tuple[str, list[str]]] = [
    # Physics nodes (must come before 2D/3D so physics subclasses don't get
    # categorised as generic 2D/3D nodes).
    ("Physics", [
        "RigidBody2D", "RigidBody3D",
        "StaticBody2D", "StaticBody3D",
        "CharacterBody2D", "CharacterBody3D",
        "KinematicBody2D", "KinematicBody3D",
        "CollisionShape2D", "CollisionShape3D",
        "Area2D", "Area3D",
        "RayCast2D", "RayCast3D",
        "ShapeCast2D", "ShapeCast3D",
        "Joint2D", "Joint3D",
        "PinJoint2D", "PinJoint3D",
        "HingeJoint3D",
    ]),
    ("Audio", [
        "AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D",
        "AudioListener",
    ]),
    ("Animation", [
        "AnimationPlayer", "AnimationTree",
        "AnimatedSprite2D", "AnimatedSprite3D",
        "Sprite2D",
    ]),
]

# These names are excluded from the catalogue (internal / not user-facing).
_EXCLUDED = frozenset({
    "DebugNode", "ShellNode",
    "PopupManager", "OverlayCanvas", "PopupPanel",
    "VirtualJoystick", "VirtualDPad", "VirtualButton",
    "DockPanel", "DockContainer",
})

def _build_categories() -> list[tuple[str, list[tuple[str, type]]]]:
    """Categorise all registered node types from ``Node._registry``.

    Returns a list of ``(category_name, [(display_name, cls), ...])``.
    """
    claimed: set[str] = set()
    categories: dict[str, list[tuple[str, type]]] = {}

    # Resolve explicit name-based rules first.
    for cat_name, names in _CATEGORY_RULES:
        entries: list[tuple[str, type]] = []
        for name in names:
            cls = Node._registry.get(name)
            if cls is not None and name not in _EXCLUDED:
                entries.append((name, cls))
                claimed.add(name)
        if entries:
            categories[cat_name] = entries

    # Now bucket all remaining registered types by inheritance.
    cat_2d: list[tuple[str, type]] = []
    cat_3d: list[tuple[str, type]] = []
    cat_ui: list[tuple[str, type]] = []
    cat_other: list[tuple[str, type]] = []

    # Lazy import to avoid circular deps at module level.
    from simvx.core.ui.core import Control as _Control

    for name, cls in sorted(Node._registry.items()):
        if name in claimed or name in _EXCLUDED or name == "Node":
            continue
        # Skip truly private / abstract helpers (leading underscore).
        if name.startswith("_"):
            continue

        if issubclass(cls, _Control):
            cat_ui.append((name, cls))
        elif issubclass(cls, Node3D):
            cat_3d.append((name, cls))
        elif issubclass(cls, Node2D):
            cat_2d.append((name, cls))
        else:
            cat_other.append((name, cls))

    # Assemble in display order.
    ordered: list[tuple[str, list[tuple[str, type]]]] = []
    if cat_2d:
        ordered.append(("2D", cat_2d))
    if cat_3d:
        ordered.append(("3D", cat_3d))
    if cat_ui:
        ordered.append(("UI", cat_ui))
    for cat_name in ("Physics", "Audio", "Animation"):
        if cat_name in categories:
            ordered.append((cat_name, categories[cat_name]))
    if cat_other:
        ordered.append(("Other", cat_other))

    return ordered

# ---------------------------------------------------------------------------
# Layout / colour constants
# ---------------------------------------------------------------------------

_WIDTH = 320.0
_HEIGHT = 440.0
_INPUT_H = 32.0
_ROW_H = 24.0
_CAT_H = 26.0
_MAX_VISIBLE_ROWS = 18
_PAD = 8.0
_FONT_SIZE = 13.0

_BG = (0.14, 0.14, 0.17, 1.0)
_INPUT_BG = (0.10, 0.10, 0.12, 1.0)
_BORDER = (0.35, 0.35, 0.40, 1.0)
_TEXT = (0.90, 0.90, 0.90, 1.0)
_DIM = (0.55, 0.55, 0.55, 1.0)
_CAT_BG = (0.18, 0.18, 0.22, 1.0)
_CAT_TEXT = (0.70, 0.82, 1.0, 1.0)
_SELECTED_BG = (0.25, 0.50, 0.85, 1.0)
_OVERLAY_BG = (0.0, 0.0, 0.0, 0.55)

# ---------------------------------------------------------------------------
# NodeCatalogue widget
# ---------------------------------------------------------------------------

[docs] class NodeCatalogue(Control): """Searchable popup for selecting a node type to add. Shows all registered ``Node`` subclasses, grouped into categories (2D, 3D, UI, Physics, Audio, Animation, Other). A search field at the top provides fuzzy filtering. Emits ``node_selected(node_class)`` when the user picks a type. Example:: catalogue = NodeCatalogue() catalogue.node_selected.connect(lambda cls: print(f"Selected {cls.__name__}")) catalogue.show_popup() """ node_selected = Signal() # emits (node_class,) def __init__(self, **kwargs): super().__init__(**kwargs) self.visible = False self.z_index = 2000 self.size = Vec2(_WIDTH, _HEIGHT) self._query: str = "" self._categories: list[tuple[str, list[tuple[str, type]]]] = [] self._rows: list[tuple[str, str, type | None]] = [] # ("cat"|"type", display, cls|None) self._selected_index: int = 0 self._scroll_offset: float = 0.0 self._collapsed: set[str] = set() self._cursor_blink: float = 0.0 self._build_categories() # -- Public API --------------------------------------------------------
[docs] def show_popup(self, position: Vec2 | None = None): """Show the catalogue popup, optionally at *position*.""" if position is not None: self.position = position self._query = "" self._selected_index = 0 self._scroll_offset = 0.0 self._collapsed.clear() self._build_categories() self._rebuild_rows() self.visible = True self.set_focus() if self._tree: self._tree.push_popup(self)
[docs] def hide_popup(self): """Dismiss the catalogue popup.""" if not self.visible: return self.visible = False self._query = "" self._rows.clear() self.release_focus() if self._tree: self._tree.pop_popup(self)
# -- Category building ------------------------------------------------- def _build_categories(self): """Categorise all registered node types.""" self._categories = _build_categories() # -- Row building / filtering ------------------------------------------ def _rebuild_rows(self): """Rebuild the flat row list from categories, applying fuzzy filter.""" rows: list[tuple[str, str, type | None]] = [] if self._query: q = self._query.lower() for cat_name, items in self._categories: scored: list[tuple[int, str, type]] = [] for name, cls in items: s = fuzzy_score(q, name.lower()) if s != -1: scored.append((s, name, cls)) if scored: scored.sort(key=lambda t: t[0]) rows.append(("cat", cat_name, None)) for _, name, cls in scored: rows.append(("type", name, cls)) else: for cat_name, items in self._categories: rows.append(("cat", cat_name, None)) if cat_name not in self._collapsed: for name, cls in items: rows.append(("type", name, cls)) self._rows = rows # Clamp selection. type_indices = [i for i, r in enumerate(rows) if r[0] == "type"] if type_indices: if self._selected_index not in type_indices: self._selected_index = type_indices[0] else: self._selected_index = -1 def _filter(self, query: str): """Filter displayed nodes by search text (fuzzy).""" self._query = query self._scroll_offset = 0.0 self._rebuild_rows() # -- Selection helpers ------------------------------------------------- def _next_type_index(self, direction: int = 1) -> int: """Move keyboard selection to the next type row.""" idx = self._selected_index + direction while 0 <= idx < len(self._rows): if self._rows[idx][0] == "type": return idx idx += direction return self._selected_index # no movement def _on_selected(self, cls: type): """Emit node_selected signal with the chosen class.""" self.node_selected.emit(cls) self.hide_popup() # -- Popup protocol ----------------------------------------------------
[docs] def is_popup_point_inside(self, point) -> bool: """Modal popup — capture all clicks.""" return self.visible
[docs] def popup_input(self, event): """Route popup input; click outside the dialogue area dismisses.""" if event.button == 1 and event.pressed: px_start = self.position[0] if hasattr(self.position, '__getitem__') else self.position.x py_start = self.position[1] if hasattr(self.position, '__getitem__') else self.position.y ex = event.position.x if hasattr(event.position, "x") else event.position[0] ey = event.position.y if hasattr(event.position, "y") else event.position[1] if not (px_start <= ex <= px_start + _WIDTH and py_start <= ey <= py_start + _HEIGHT): self.hide_popup() return # Click inside the list area — hit-test rows. list_y = py_start + _INPUT_H if ey >= list_y: local_y = ey - list_y + self._scroll_offset cum = 0.0 for i, (kind, name, cls) in enumerate(self._rows): rh = _CAT_H if kind == "cat" else _ROW_H if cum <= local_y < cum + rh: if kind == "cat": if name in self._collapsed: self._collapsed.discard(name) else: self._collapsed.add(name) self._rebuild_rows() elif cls is not None: self._on_selected(cls) return cum += rh return self._on_gui_input(event)
[docs] def dismiss_popup(self): self.hide_popup()
# -- Input ------------------------------------------------------------- def _on_gui_input(self, event: UIInputEvent): if not self.visible: return if event.key == "escape" and event.pressed: self.hide_popup() return if event.key == "enter" and event.pressed: if 0 <= self._selected_index < len(self._rows): kind, name, cls = self._rows[self._selected_index] if kind == "type" and cls is not None: self._on_selected(cls) return if event.key == "up" and event.pressed: self._selected_index = self._next_type_index(-1) return if event.key == "down" and event.pressed: self._selected_index = self._next_type_index(1) return if event.key == "backspace" and event.pressed: if self._query: self._filter(self._query[:-1]) return if event.char and len(event.char) == 1: self._filter(self._query + event.char) # -- Process / Draw ----------------------------------------------------
[docs] def process(self, dt: float): if self.visible: self._cursor_blink += dt if self._cursor_blink > 1.0: self._cursor_blink = 0.0
[docs] def draw(self, renderer): pass # Draw in popup pass only.
[docs] def draw_popup(self, renderer): if not self.visible: return ss = self._get_parent_size() sw, sh = ss.x, ss.y scale = _FONT_SIZE / 14.0 # Overlay backdrop renderer.draw_rect((0, 0), (sw, sh), colour=_OVERLAY_BG, filled=True) px = self.position[0] if hasattr(self.position, '__getitem__') else self.position.x py_base = self.position[1] if hasattr(self.position, '__getitem__') else self.position.y # Background + border renderer.draw_rect((px, py_base), (_WIDTH, _HEIGHT), colour=_BG, filled=True) renderer.draw_rect((px, py_base), (_WIDTH, _HEIGHT), colour=_BORDER) # Search field renderer.draw_rect((px + 2, py_base + 2), (_WIDTH - 4, _INPUT_H - 2), colour=_INPUT_BG, filled=True) display_text = f"> {self._query}" if self._query else "> Filter..." text_colour = _TEXT if self._query else _DIM ty = py_base + (_INPUT_H - _FONT_SIZE) / 2 renderer.draw_text(display_text, (px + _PAD, ty), colour=text_colour, scale=scale) # Cursor blink if self._query and self._cursor_blink < 0.5: cursor_x = px + _PAD + renderer.text_width(f"> {self._query}", scale) renderer.draw_line( (cursor_x, py_base + 6), (cursor_x, py_base + _INPUT_H - 6), colour=_TEXT ) # Category + type rows ry = py_base + _INPUT_H max_y = py_base + _HEIGHT cum = 0.0 for i, (kind, name, cls) in enumerate(self._rows): rh = _CAT_H if kind == "cat" else _ROW_H row_top = ry + cum - self._scroll_offset cum += rh if row_top + rh < ry or row_top >= max_y: continue # clipped if kind == "cat": renderer.draw_rect( (px + 2, row_top), (_WIDTH - 4, _CAT_H), colour=_CAT_BG, filled=True ) arrow = "\u25b8" if name in self._collapsed else "\u25be" renderer.draw_text( f"{arrow} {name}", (px + _PAD, row_top + (_CAT_H - _FONT_SIZE) / 2), colour=_CAT_TEXT, scale=scale, ) else: if i == self._selected_index: renderer.draw_rect( (px + 2, row_top), (_WIDTH - 4, _ROW_H), colour=_SELECTED_BG, filled=True ) renderer.draw_text( f" {name}", (px + _PAD, row_top + (_ROW_H - _FONT_SIZE) / 2), colour=_TEXT, scale=scale, )