"""Scene Tree dialogs: Add Node popup and inline rename overlay."""
from __future__ import annotations
from typing import TYPE_CHECKING
from simvx.core import (
# Animation
Control,
MouseButton,
# 2D Lights
Node,
Signal,
TextEdit,
# TileMap
Vec2,
)
if TYPE_CHECKING:
from simvx.editor.project_classes import ProjectClass, ProjectClassIndex
from .type_registry import (
_DEFAULT_EXPANDED,
_NODE_CATEGORIES,
_NODE_DESCRIPTIONS,
_NODE_ICONS,
_RECENT_TYPES,
_get_inheritance_chain,
_record_recent_type,
)
__all__ = ["_AddNodeDialog", "_RenameOverlay"]
# ============================================================================
class _AddNodeDialog(Control):
"""Popup overlay listing available node types in collapsible categories with a filter.
Uses the SceneTree popup system so it draws on top of all controls
and receives input before any underlying widgets.
Features:
- Collapsible categories with type icons
- Text filter with real-time narrowing
- Keyboard navigation (Up/Down/Enter/Escape/Tab)
- Recently used types section
- Description footer with inheritance chain
- Scroll indicator (visual scrollbar)
Emits *type_chosen(node_class)* when the user picks a type, then hides.
"""
DIALOG_WIDTH = 300.0
DIALOG_HEIGHT = 480.0
ROW_HEIGHT = 22.0
CATEGORY_HEIGHT = 24.0
HEADER_HEIGHT = 50.0
FOOTER_HEIGHT = 50.0
SCROLLBAR_WIDTH = 4.0
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.modal = True
self.dismiss_on_outside_click = True
self.pause_tree_when_modal = False
self.top_level = True
self.visible = False
self.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT)
self.cancel_requested.connect(self._on_router_cancel)
self.bg_colour = (0.14, 0.14, 0.17, 1.0)
self.border_colour = (0.38, 0.38, 0.42, 1.0)
self.text_colour = (0.92, 0.92, 0.92, 1.0)
self.hover_colour = (0.30, 0.47, 0.77, 1.0)
self.select_colour = (0.36, 0.55, 0.88, 1.0)
self.category_bg = (0.18, 0.18, 0.22, 1.0)
self.category_text = (0.70, 0.82, 1.0, 1.0)
self.footer_bg = (0.12, 0.12, 0.15, 1.0)
self.footer_sep = (0.30, 0.30, 0.35, 1.0)
self.desc_colour = (0.60, 0.62, 0.66, 1.0)
self.scrollbar_colour = (0.40, 0.42, 0.48, 0.6)
self.font_size = 13.0
self._filter_text = ""
# Visible rows: list of ("category", cat_name, None) or ("type", name, cls).
# ``cls`` is either a live ``type`` (built-in) or a ``ProjectClass``
# record (user-defined, resolved on selection).
self._rows: list[tuple[str, str, object | None]] = []
self._hovered_index = -1
self._selected_index = -1 # keyboard selection
self._scroll_offset = 0.0
self._collapsed: set[str] = set() # collapsed category names
self._focus_in_list = False # True when keyboard focus is in the type list
# Optional project-class index. Wired by the panel after construction.
self._project_index: ProjectClassIndex | None = None
self._project_classes: list[ProjectClass] = []
# Filter text-edit (embedded)
self._filter_edit = TextEdit(placeholder="Filter types...", name="AddNodeFilter")
self._filter_edit.size = Vec2(self.DIALOG_WIDTH - 12, 24)
self._filter_edit.font_size = 12.0
self._filter_edit.text_changed.connect(self._on_filter_changed)
self.add_child(self._filter_edit)
# Emitted with either a built-in ``type`` or a ``ProjectClass`` record;
# the panel resolves project entries to live classes before instantiating.
self.type_chosen = Signal() # Direct add (at default position)
self.type_place_chosen = Signal() # Place with mouse (Shift+click)
self._rebuild_rows()
def set_project_index(self, index: ProjectClassIndex | None) -> None:
"""Wire (or clear) the project-class discovery cache.
Called by :class:`SceneTreePanel` once the editor has a project root.
The dialog refreshes the index lazily on each :meth:`show_at`.
"""
self._project_index = index
# -- public API --
def show_at(self, x: float, y: float):
"""Open the dialog at the given screen position."""
self.position = Vec2(x, y)
self._filter_text = ""
self._filter_edit.text = ""
self._hovered_index = -1
self._selected_index = -1
self._scroll_offset = 0.0
self._focus_in_list = False
if self._project_index is not None:
self._project_classes = self._project_index.refresh()
else:
self._project_classes = []
self._collapsed = {cat for cat, _ in _NODE_CATEGORIES if cat not in _DEFAULT_EXPANDED}
self._rebuild_rows()
self.show_modal()
if self._tree:
# Override the default first-focusable focus with the filter edit.
self._tree._set_focused_control(self._filter_edit)
def dismiss(self):
if not self.visible:
return
self._hovered_index = -1
self._selected_index = -1
self.close_modal()
def _on_router_cancel(self):
if self.visible:
self.dismiss()
# -- keyboard navigation --
def _next_type_index(self, start: int, direction: int = 1) -> int:
"""Find the next type row index from *start* in *direction* (1=down, -1=up).
Skips category header rows. Returns -1 if no type row found.
"""
idx = start + direction
while 0 <= idx < len(self._rows):
if self._rows[idx][0] == "type":
return idx
idx += direction
return -1
def _ensure_selected_visible(self):
"""Scroll so the keyboard-selected row is within the visible list area."""
if self._selected_index < 0:
return
list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
row_top = self._row_y_offset(self._selected_index)
row_bot = row_top + self._row_height(self._selected_index)
if row_top < self._scroll_offset:
self._scroll_offset = row_top
elif row_bot > self._scroll_offset + list_h:
self._scroll_offset = row_bot - list_h
def _confirm_selection(self, shift: bool = False):
"""Confirm the currently keyboard-selected type."""
if 0 <= self._selected_index < len(self._rows):
kind, name, cls = self._rows[self._selected_index]
if kind == "type" and cls is not None:
if shift:
self.type_place_chosen.emit(cls)
else:
if isinstance(cls, type):
_record_recent_type(cls)
self.type_chosen.emit(cls)
self.dismiss()
# -- row layout helpers --
def _row_height(self, idx: int) -> float:
"""Return the pixel height of row at *idx*."""
if 0 <= idx < len(self._rows):
return self.CATEGORY_HEIGHT if self._rows[idx][0] == "category" else self.ROW_HEIGHT
return self.ROW_HEIGHT
def _row_y_offset(self, idx: int) -> float:
"""Return cumulative Y offset to the top of row *idx*."""
y = 0.0
for i in range(idx):
y += self._row_height(i)
return y
def _total_content_height(self) -> float:
"""Total pixel height of all visible rows."""
return sum(self._row_height(i) for i in range(len(self._rows)))
def _hit_test_row(self, local_y: float) -> int | None:
"""Given a Y offset within the list area, return the row index or None."""
y = 0.0
for i in range(len(self._rows)):
h = self._row_height(i)
if y <= local_y < y + h:
return i
y += h
return None
def type_row_y(self, type_name: str) -> float | None:
"""Return the global Y position of the named type row, or None if not visible.
Used by demo step handlers to compute click targets in the categorised layout.
"""
_, gy, _, _ = self.get_global_rect()
y = gy + self.HEADER_HEIGHT
for kind, name, _cls in self._rows:
h = self.CATEGORY_HEIGHT if kind == "category" else self.ROW_HEIGHT
if kind == "type" and name == type_name:
return y
y += h
return None
# -- scroll indicator helpers --
def _scroll_indicator_geometry(self) -> tuple[float, float, float, float] | None:
"""Return (x, y, w, h) for the scroll thumb, or None if content fits."""
list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
total_h = self._total_content_height()
if total_h <= list_h:
return None
gx, gy, gw, _ = self.get_global_rect()
track_x = gx + gw - self.SCROLLBAR_WIDTH - 1
track_y = gy + self.HEADER_HEIGHT
ratio = list_h / total_h
thumb_h = max(12.0, list_h * ratio)
scroll_range = total_h - list_h
if scroll_range > 0:
thumb_y = track_y + (self._scroll_offset / scroll_range) * (list_h - thumb_h)
else:
thumb_y = track_y
return track_x, thumb_y, self.SCROLLBAR_WIDTH, thumb_h
# -- description footer helpers --
def _focused_type(self) -> object | None:
"""Return the entry currently under keyboard selection or hover.
May be either a built-in ``type`` or a :class:`ProjectClass` record.
"""
idx = self._selected_index if self._selected_index >= 0 else self._hovered_index
if 0 <= idx < len(self._rows):
kind, _, cls = self._rows[idx]
if kind == "type":
return cls
return None
# -- internals --
def _row_module_path(self, entry: object) -> str:
"""Module path shown as secondary text and matched by the filter.
Built-ins are normalised to their public namespace (``simvx.core``)
rather than the implementation submodule (``simvx.core.node``) so the
filter matches the user's mental model and doesn't sweep in every
class whose source file happens to be named ``node.py``.
"""
from simvx.editor.project_classes import ProjectClass
if isinstance(entry, ProjectClass):
return entry.module_path
if isinstance(entry, type):
module = getattr(entry, "__module__", "") or ""
# Collapse simvx.core.* -> simvx.core; same for simvx.graphics.*, etc.
if module.startswith("simvx."):
parts = module.split(".")
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
return module
return ""
def _row_class_name(self, entry: object) -> str:
"""Display name for an entry (built-in ``type`` or ``ProjectClass``)."""
from simvx.editor.project_classes import ProjectClass
if isinstance(entry, ProjectClass):
return entry.name
if isinstance(entry, type):
return entry.__name__
return ""
def _row_matches_filter(self, name: str, entry: object) -> bool:
"""Substring match across both class name and module path."""
if not self._filter_text:
return True
ft = self._filter_text
return ft in name.lower() or ft in self._row_module_path(entry).lower()
def _rebuild_rows(self):
"""Rebuild the flat row list from categories, respecting filter and collapse state."""
rows: list[tuple[str, str, object | None]] = []
ft = self._filter_text
project_entries: list[tuple[str, ProjectClass]] = [
(pc.name, pc) for pc in self._project_classes
]
if ft:
# Filter mode: show all matching items grouped by category (all expanded).
# Project entries appear at the top so user classes lead the list.
project_matches = [
(name, pc) for name, pc in project_entries if self._row_matches_filter(name, pc)
]
if project_matches:
rows.append(("category", "Project", None))
for name, pc in project_matches:
rows.append(("type", name, pc))
for cat_name, items in _NODE_CATEGORIES:
matches = [(name, cls) for name, cls in items if self._row_matches_filter(name, cls)]
if matches:
rows.append(("category", cat_name, None))
for name, cls in matches:
rows.append(("type", name, cls))
else:
# Project section: always expanded and at the top when non-empty.
if project_entries:
rows.append(("category", "Project", None))
for name, pc in project_entries:
rows.append(("type", name, pc))
# Recent section: in-session most-recently-used types (built-in only;
# user classes can be added through Project or via filter).
if _RECENT_TYPES:
rows.append(("category", "Recent", None))
seen: set[type] = set()
for cls in _RECENT_TYPES:
if cls not in seen:
seen.add(cls)
rows.append(("type", cls.__name__, cls))
# Normal mode: respect collapse state
for cat_name, items in _NODE_CATEGORIES:
rows.append(("category", cat_name, None))
if cat_name not in self._collapsed:
for name, cls in items:
rows.append(("type", name, cls))
self._rows = rows
@property
def _filtered(self) -> list[tuple[str, object]]:
"""Backward-compatible flat list of visible (name, cls) tuples (excludes category headers)."""
return [(name, cls) for kind, name, cls in self._rows if kind == "type" and cls is not None]
def _on_filter_changed(self, text: str):
self._filter_text = text.lower()
self._hovered_index = -1
self._selected_index = -1
self._scroll_offset = 0.0
self._rebuild_rows()
def _on_gui_input(self, event):
"""Handle keyboard navigation, hover tracking, scroll, and clicks."""
if not self.visible:
return
# Mouse press: route based on click region (filter area vs list area).
if event.button == MouseButton.LEFT and event.pressed and event.position is not None:
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]
gx, gy, gw, gh = self.get_global_rect()
# Click outside the dialog rect: dismiss.
if not (gx <= px < gx + gw and gy <= py < gy + gh):
self.dismiss()
event.handled = True
return
list_y_start = gy + self.HEADER_HEIGHT
if py < list_y_start:
self._focus_in_list = False
if self._tree:
self._tree._set_focused_control(self._filter_edit)
return
footer_y = gy + gh - self.FOOTER_HEIGHT
if list_y_start <= py < footer_y:
self._focus_in_list = True
row_idx = self._hit_test_row(py - list_y_start + self._scroll_offset)
if row_idx is not None and 0 <= row_idx < len(self._rows):
kind, name, cls = self._rows[row_idx]
if kind == "category":
if name in self._collapsed:
self._collapsed.discard(name)
else:
self._collapsed.add(name)
self._rebuild_rows()
event.handled = True
return
if kind == "type" and cls is not None:
self._selected_index = row_idx
shift = getattr(event, "shift", False)
if shift:
self.type_place_chosen.emit(cls)
else:
if isinstance(cls, type):
_record_recent_type(cls)
self.type_chosen.emit(cls)
self.dismiss()
event.handled = True
return
# Handle keyboard events (key presses only, not releases or char events)
if event.key and event.button is None and event.pressed:
key = event.key
if key == "escape":
self.dismiss()
return
if key == "down":
start = self._selected_index if self._selected_index >= 0 else -1
nxt = self._next_type_index(start, 1)
if nxt >= 0:
self._selected_index = nxt
self._focus_in_list = True
self._ensure_selected_visible()
return
if key == "up":
if self._selected_index >= 0:
nxt = self._next_type_index(self._selected_index, -1)
if nxt >= 0:
self._selected_index = nxt
self._ensure_selected_visible()
else:
# At top of list -- move focus back to filter
self._selected_index = -1
self._focus_in_list = False
if self._tree:
self._tree._set_focused_control(self._filter_edit)
return
if key in ("enter", "return"):
if self._selected_index >= 0:
self._confirm_selection()
return
if key == "tab":
if self._focus_in_list:
# Tab back to filter
self._focus_in_list = False
self._selected_index = -1
if self._tree:
self._tree._set_focused_control(self._filter_edit)
else:
# Tab into list -- select first type row
nxt = self._next_type_index(-1, 1)
if nxt >= 0:
self._selected_index = nxt
self._focus_in_list = True
self._ensure_selected_visible()
return
if key in ("scroll_up", "scroll_down"):
delta = -30.0 if key == "scroll_up" else 30.0
list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
max_scroll = max(0.0, self._total_content_height() - list_h)
self._scroll_offset = max(0.0, min(max_scroll, self._scroll_offset + delta))
return
# Forward char events to filter (typing text)
if event.char:
self._filter_edit._internal_gui_input(event)
return
# Forward non-navigation key events to filter (e.g. backspace)
if event.key and event.button is None:
if event.key not in ("escape", "down", "up", "enter", "return", "tab", "scroll_up", "scroll_down"):
self._filter_edit._internal_gui_input(event)
return
# Mouse move -- hover tracking
py = (
event.position[1]
if isinstance(event.position, tuple | list)
else (event.position.y if hasattr(event.position, "y") else 0)
)
gx, gy, gw, gh = self.get_global_rect()
list_y_start = gy + self.HEADER_HEIGHT
footer_y = gy + gh - self.FOOTER_HEIGHT
# Hit-test rows for hover highlighting
if list_y_start <= py < footer_y:
row_idx = self._hit_test_row(py - list_y_start + self._scroll_offset)
if row_idx is not None and 0 <= row_idx < len(self._rows) and self._rows[row_idx][0] == "type":
self._hovered_index = row_idx
else:
self._hovered_index = -1
else:
self._hovered_index = -1
def on_draw(self, renderer):
"""Draw the dialog (top_level so it renders above siblings)."""
if not self.visible:
return
gx, gy, gw, gh = self.get_global_rect()
# Background + border
renderer.draw_rect((gx, gy), (gw, gh), colour=self.bg_colour, filled=True)
renderer.draw_rect((gx, gy), (gw, gh), colour=self.border_colour)
# Title and hint
renderer.draw_text("Add Node", (gx + 6, gy + 2), colour=(0.7, 0.85, 1.0, 1.0), scale=self.font_size / 14.0)
renderer.draw_text("Shift = place", (gx + gw - 80, gy + 4), colour=(0.5, 0.5, 0.5, 1.0), scale=0.55)
# Position and draw filter edit (below title)
self._filter_edit.position = Vec2(6, 20)
self._filter_edit._draw_recursive(renderer)
# List area (between header and footer)
list_y = gy + self.HEADER_HEIGHT
list_h = gh - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
renderer.push_clip(gx, list_y, gw, list_h)
scale = self.font_size / 14.0
y_cursor = 0.0
for i, (kind, name, cls) in enumerate(self._rows):
rh = self.CATEGORY_HEIGHT if kind == "category" else self.ROW_HEIGHT
row_y = list_y + y_cursor - self._scroll_offset
y_cursor += rh
# Cull off-screen rows
if row_y + rh < list_y or row_y > list_y + list_h:
continue
if kind == "category":
# Category header row
renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.category_bg, filled=True)
collapsed = name in self._collapsed and not self._filter_text
# Recent category is never collapsible
if name == "Recent":
arrow = "\u25bc"
else:
arrow = "\u25b6" if collapsed else "\u25bc" # right or down arrow
renderer.draw_text(
f" {arrow} {name}", (gx + 4, row_y + 4), colour=self.category_text, scale=scale
)
else:
# Type row (indented)
is_selected = (i == self._selected_index)
is_hovered = (i == self._hovered_index)
if is_selected:
renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.select_colour, filled=True)
elif is_hovered:
renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.hover_colour, filled=True)
icon = _NODE_ICONS.get(cls, "\u2295") if isinstance(cls, type) else "\u2295"
renderer.draw_text(
f" {icon} {name}", (gx + 4, row_y + 3), colour=self.text_colour, scale=scale
)
# Module path -- right-aligned secondary text. Shown for both
# built-ins (``simvx.core``) and project classes (``player.attack``)
# so the picker disambiguates same-named symbols.
module_path = self._row_module_path(cls)
if module_path:
# Trim to a tail that fits the right gutter.
max_chars = 28
if len(module_path) > max_chars:
module_path = "..." + module_path[-(max_chars - 3):]
text_x = gx + gw - len(module_path) * 6 - 12
renderer.draw_text(
module_path, (text_x, row_y + 4), colour=self.desc_colour, scale=scale * 0.8
)
renderer.pop_clip()
# -- Scroll indicator --
geom = self._scroll_indicator_geometry()
if geom:
sx, sy, sw, sh = geom
renderer.draw_rect((sx, sy), (sw, sh), colour=self.scrollbar_colour, filled=True)
# -- Description footer --
footer_y = gy + gh - self.FOOTER_HEIGHT
renderer.draw_rect((gx, footer_y), (gw, self.FOOTER_HEIGHT), colour=self.footer_bg, filled=True)
renderer.draw_line((gx, footer_y), (gx + gw, footer_y), colour=self.footer_sep)
focused = self._focused_type()
if focused is not None:
from simvx.editor.project_classes import ProjectClass
if isinstance(focused, ProjectClass):
type_name = focused.name
chain = ", ".join(focused.bases)
# Description for project classes: the dotted module path so
# users can locate the source file at a glance.
desc = focused.module_path or str(focused.file_path)
elif isinstance(focused, type):
type_name = focused.__name__
chain = _get_inheritance_chain(focused)
desc = _NODE_DESCRIPTIONS.get(focused, "")
else:
type_name = ""
chain = ""
desc = ""
title_text = f"{type_name} extends {chain}" if chain else type_name
renderer.draw_text(title_text, (gx + 6, footer_y + 6), colour=self.text_colour, scale=scale * 0.95)
if desc:
renderer.draw_text(desc, (gx + 6, footer_y + 24), colour=self.desc_colour, scale=scale * 0.85)
# ============================================================================
# Rename overlay -- small inline text field for renaming a node
# ============================================================================
class _RenameOverlay(Control):
"""Inline text field shown over a tree row to rename a node."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.visible = False
self._edit = TextEdit(name="RenameEdit")
self._edit.size = Vec2(180, 22)
self._edit.font_size = 13.0
self.add_child(self._edit)
self._target_node: Node | None = None
self.rename_confirmed = Signal()
self._edit.text_submitted.connect(self._on_submit)
def begin(self, node: Node, x: float, y: float):
self._target_node = node
self._edit.text = node.name
self._edit.cursor_pos = len(node.name)
self.position = Vec2(x, y)
self.size = Vec2(180, 22)
self.visible = True
if self._tree:
self._tree._set_focused_control(self._edit)
def _on_submit(self, text: str):
if self._target_node and text.strip():
self.rename_confirmed.emit(self._target_node, text.strip())
self.visible = False
self._target_node = None
def cancel(self):
self.visible = False
self._target_node = None
def on_draw(self, renderer):
if not self.visible:
return
# The child TextEdit handles its own drawing
# ============================================================================
# SceneTreePanel -- main panel