"""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 --------------------------------------------------------
# -- 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 ----------------------------------------------------
# -- 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.