"""Scene Tree Panel -- hierarchical view of the scene's node tree.
Displays the node hierarchy from the edited scene, supports selection,
right-click context menu, add/delete/duplicate/rename/copy/paste actions,
and an Add Node dialog for creating new nodes by type. All mutating
operations go through the UndoStack so they are fully reversible.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from simvx.core import (
# Animation
AnimatedSprite2D,
AnimationPlayer,
AnimationTree,
# Audio
AudioStreamPlayer,
AudioStreamPlayer2D,
AudioStreamPlayer3D,
# UI
Button,
CallableCommand,
Camera2D,
Camera3D,
CanvasLayer,
CanvasModulate,
# Physics 2D
CharacterBody2D,
# Physics 3D
CharacterBody3D,
CheckBox,
CollisionShape2D,
CollisionShape3D,
ColourPicker,
Control,
# 2D Lights
DirectionalLight2D,
# 3D Lights
DirectionalLight3D,
DropDown,
GridContainer,
HBoxContainer,
# Physics joints
HingeJoint3D,
KinematicBody2D,
KinematicBody3D,
Label,
Light3D,
LightOccluder2D,
Line2D,
Marker2D,
Marker3D,
MarginContainer,
MenuItem,
MeshInstance3D,
MultiLineTextEdit,
# Navigation
NavigationAgent2D,
NavigationAgent3D,
NavigationObstacle3D,
NinePatchRect,
Node,
Node2D,
Node3D,
OrbitCamera3D,
Panel,
ParallaxBackground,
ParallaxLayer,
# Particles
ParticleEmitter,
Path2D,
Path3D,
PathFollow2D,
PathFollow3D,
PointLight2D,
PointLight3D,
Polygon2D,
PopupMenu,
ProgressBar,
Area2D,
Area3D,
RayCast2D,
RayCast3D,
RichTextLabel,
RigidBody2D,
RigidBody3D,
ScrollContainer,
ShapeCast2D,
ShapeCast3D,
Signal,
Skeleton,
Slider,
SpinBox,
SplitContainer,
SpotLight3D,
Sprite2D,
StaticBody2D,
StaticBody3D,
# Viewport
SubViewport,
TabContainer,
Text2D,
Text3D,
TextEdit,
# TileMap
TileMap,
Timer,
TreeItem,
TreeView,
VBoxContainer,
Vec2,
ViewportContainer,
WorldEnvironment,
YSortContainer,
PinJoint3D,
)
if TYPE_CHECKING:
from simvx.editor.state import EditorState
__all__ = ["SceneTreePanel", "register_addable_type"]
# ---------------------------------------------------------------------------
# Icon mapping -- text icons for each node type
# ---------------------------------------------------------------------------
_NODE_ICONS: dict[type, str] = {
# 2D Nodes
Node2D: "\u25c8", # diamond
Sprite2D: "\u229e", # squared plus (sprite)
AnimatedSprite2D: "\u229e",
Camera2D: "\u2299", # circled dot
Line2D: "\u2571", # diagonal
Polygon2D: "\u25b3", # triangle
Path2D: "\u223f", # sine wave (curve)
PathFollow2D: "\u223f",
Marker2D: "\u271b", # cross mark
NinePatchRect: "\u25a3", # filled square with border
YSortContainer: "\u21c5", # up-down arrows
CanvasLayer: "\u25a2", # white square with border
CanvasModulate: "\u25a7", # hatched square
ParallaxBackground: "\u2261", # triple bar
ParallaxLayer: "\u2261",
Text2D: "T",
# 3D Nodes
Node3D: "\u25c8", # diamond
Camera3D: "\u2299", # circled dot
OrbitCamera3D: "\u2299",
MeshInstance3D: "\u25a1", # white square
Text3D: "T",
Path3D: "\u223f",
PathFollow3D: "\u223f",
Marker3D: "\u271b",
# Physics 2D
CharacterBody2D: "\u25c6", # filled diamond (body)
RigidBody2D: "\u25c6",
StaticBody2D: "\u25c6",
KinematicBody2D: "\u25c6",
Area2D: "\u2298", # circled division (area)
CollisionShape2D: "\u2b21", # hexagon (shape)
RayCast2D: "\u2197", # northeast arrow (raycast)
ShapeCast2D: "\u2197",
# Physics 3D
CharacterBody3D: "\u25c6",
RigidBody3D: "\u25c6",
StaticBody3D: "\u25c6",
KinematicBody3D: "\u25c6",
Area3D: "\u2298",
CollisionShape3D: "\u2b21",
RayCast3D: "\u2197",
ShapeCast3D: "\u2197",
PinJoint3D: "\u2699", # gear (joint)
HingeJoint3D: "\u2699",
# Lights
DirectionalLight3D: "\u2600", # sun
PointLight3D: "\u2600",
SpotLight3D: "\u2600",
Light3D: "\u2600",
PointLight2D: "\u2600",
DirectionalLight2D: "\u2600",
LightOccluder2D: "\u25d1", # half circle
# Animation
AnimationPlayer: "\u25b6", # play button
AnimationTree: "\u25b6",
Skeleton: "\u2610", # ballot box (skeleton)
# Audio
AudioStreamPlayer: "\u266a", # eighth note
AudioStreamPlayer2D: "\u266a",
AudioStreamPlayer3D: "\u266a",
# Navigation
NavigationAgent2D: "\u25b8", # right-pointing triangle (agent)
NavigationAgent3D: "\u25b8",
NavigationObstacle3D: "\u2297", # circled times (obstacle)
# Particles
ParticleEmitter: "\u2726", # four-pointed star
# TileMap
TileMap: "\u25a6", # squared with diagonal fill
# UI
Control: "\u25a1",
Button: "\u2b1c", # white square
Label: "A",
Panel: "\u25ad", # rectangle
TextEdit: "\u270e", # pencil
MultiLineTextEdit: "\u270e",
CheckBox: "\u2611", # ballot box with check
SpinBox: "\u2195", # up-down arrow
Slider: "\u2500", # horizontal line
ProgressBar: "\u2588", # full block
DropDown: "\u25be", # down-pointing triangle
ColourPicker: "\u25d3", # circle with fill
TreeView: "\u2514", # box drawing
RichTextLabel: "\u00b6", # pilcrow
# Containers
VBoxContainer: "\u2b0d", # vertical arrows
HBoxContainer: "\u2b0c", # horizontal arrows
GridContainer: "\u229e", # grid
MarginContainer: "\u25a2",
ScrollContainer: "\u21f3", # up-down arrow with bar
SplitContainer: "\u2502", # vertical line
TabContainer: "\u2630", # trigram
# Viewport
SubViewport: "\u25a3",
ViewportContainer: "\u25a3",
WorldEnvironment: "\u2609", # sun symbol
# Misc
Node: "\u2295", # circled plus
Timer: "\u23f1", # stopwatch
}
# ---------------------------------------------------------------------------
# Categorised type registry for the Add Node dialog
# ---------------------------------------------------------------------------
# Each category: (category_name, [(display_name, node_class), ...])
_NODE_CATEGORIES: list[tuple[str, list[tuple[str, type]]]] = [
("2D Nodes", [
("Node2D", Node2D),
("Sprite2D", Sprite2D),
("AnimatedSprite2D", AnimatedSprite2D),
("Camera2D", Camera2D),
("Line2D", Line2D),
("Polygon2D", Polygon2D),
("Path2D", Path2D),
("PathFollow2D", PathFollow2D),
("Marker2D", Marker2D),
("NinePatchRect", NinePatchRect),
("YSortContainer", YSortContainer),
("CanvasLayer", CanvasLayer),
("CanvasModulate", CanvasModulate),
("ParallaxBackground", ParallaxBackground),
("ParallaxLayer", ParallaxLayer),
("Text2D", Text2D),
]),
("3D Nodes", [
("Node3D", Node3D),
("Camera3D", Camera3D),
("OrbitCamera3D", OrbitCamera3D),
("MeshInstance3D", MeshInstance3D),
("Text3D", Text3D),
("Path3D", Path3D),
("PathFollow3D", PathFollow3D),
("Marker3D", Marker3D),
]),
("Physics 2D", [
("CharacterBody2D", CharacterBody2D),
("RigidBody2D", RigidBody2D),
("StaticBody2D", StaticBody2D),
("KinematicBody2D", KinematicBody2D),
("Area2D", Area2D),
("CollisionShape2D", CollisionShape2D),
("RayCast2D", RayCast2D),
("ShapeCast2D", ShapeCast2D),
]),
("Physics 3D", [
("CharacterBody3D", CharacterBody3D),
("RigidBody3D", RigidBody3D),
("StaticBody3D", StaticBody3D),
("KinematicBody3D", KinematicBody3D),
("Area3D", Area3D),
("CollisionShape3D", CollisionShape3D),
("RayCast3D", RayCast3D),
("ShapeCast3D", ShapeCast3D),
("PinJoint3D", PinJoint3D),
("HingeJoint3D", HingeJoint3D),
]),
("Lights", [
("DirectionalLight3D", DirectionalLight3D),
("PointLight3D", PointLight3D),
("SpotLight3D", SpotLight3D),
("PointLight2D", PointLight2D),
("DirectionalLight2D", DirectionalLight2D),
("LightOccluder2D", LightOccluder2D),
]),
("Animation", [
("AnimationPlayer", AnimationPlayer),
("AnimationTree", AnimationTree),
]),
("Audio", [
("AudioStreamPlayer", AudioStreamPlayer),
("AudioStreamPlayer2D", AudioStreamPlayer2D),
("AudioStreamPlayer3D", AudioStreamPlayer3D),
]),
("Navigation", [
("NavigationAgent2D", NavigationAgent2D),
("NavigationAgent3D", NavigationAgent3D),
("NavigationObstacle3D", NavigationObstacle3D),
]),
("Particles", [
("ParticleEmitter", ParticleEmitter),
]),
("TileMap", [
("TileMap", TileMap),
]),
("UI", [
("Control", Control),
("Button", Button),
("Label", Label),
("Panel", Panel),
("TextEdit", TextEdit),
("MultiLineTextEdit", MultiLineTextEdit),
("CheckBox", CheckBox),
("SpinBox", SpinBox),
("Slider", Slider),
("ProgressBar", ProgressBar),
("DropDown", DropDown),
("ColourPicker", ColourPicker),
("TreeView", TreeView),
("RichTextLabel", RichTextLabel),
]),
("Containers", [
("HBoxContainer", HBoxContainer),
("VBoxContainer", VBoxContainer),
("GridContainer", GridContainer),
("MarginContainer", MarginContainer),
("ScrollContainer", ScrollContainer),
("SplitContainer", SplitContainer),
("TabContainer", TabContainer),
]),
("Viewport", [
("SubViewport", SubViewport),
("ViewportContainer", ViewportContainer),
("WorldEnvironment", WorldEnvironment),
]),
("Misc", [
("Node", Node),
("Timer", Timer),
]),
]
# Default-expanded categories when no filter is active
_DEFAULT_EXPANDED = {"2D Nodes", "3D Nodes", "UI"}
# Flat list for backward compatibility (rebuilt from categories)
_ADDABLE_TYPES: list[tuple[str, type]] = []
for _cat_name, _cat_items in _NODE_CATEGORIES:
_ADDABLE_TYPES.extend(_cat_items)
[docs]
def register_addable_type(name: str, cls: type, icon: str = "\u2295", category: str = "Custom"):
"""Register a custom node type so it appears in the Add Node dialog.
Args:
name: Display name in the dialog (e.g. ``"MyCustomNode"``).
cls: The node class to instantiate when selected.
icon: Optional text icon (default: circled plus).
category: Category to place the type under (default: ``"Custom"``).
"""
# Avoid duplicates across all categories
for _, items in _NODE_CATEGORIES:
for existing_name, _ in items:
if existing_name == name:
return
# Find or create the target category
for cat_name, items in _NODE_CATEGORIES:
if cat_name == category:
items.append((name, cls))
break
else:
_NODE_CATEGORIES.append((category, [(name, cls)]))
_ADDABLE_TYPES.append((name, cls))
_NODE_ICONS.setdefault(cls, icon)
def _get_node_icon(node: Node) -> str:
"""Return a text icon for *node* based on its most specific type."""
for cls in type(node).__mro__:
if cls in _NODE_ICONS:
return _NODE_ICONS[cls]
return "\u2295"
# ---------------------------------------------------------------------------
# Node type descriptions -- one-line summary for the Add Node dialog footer
# ---------------------------------------------------------------------------
_NODE_DESCRIPTIONS: dict[type, str] = {
# 2D Nodes
Node2D: "2D game object with position, rotation, and scale",
Sprite2D: "Draws a 2D textured rectangle",
AnimatedSprite2D: "Plays 2D sprite animations from a sprite sheet",
Camera2D: "2D camera with follow, zoom, bounds, and shake",
Line2D: "Draws a polyline with configurable width",
Polygon2D: "Draws a filled polygon from vertex points",
Path2D: "Holds a Curve2D for spline-based movement",
PathFollow2D: "Follows a parent Path2D curve by distance",
Marker2D: "Position marker for spawn points and waypoints (2D)",
NinePatchRect: "9-slice bordered texture for UI panels",
YSortContainer: "Sorts children by Y position for top-down depth",
CanvasLayer: "Fixed-transform layer for HUD/overlay",
CanvasModulate: "Tints all 2D rendering on the parent canvas layer",
ParallaxBackground: "Scrolling parallax background container",
ParallaxLayer: "Single scrolling layer in a parallax background",
Text2D: "Renders text as a 2D node in world space",
# 3D Nodes
Node3D: "3D game object with position, rotation, and scale",
Camera3D: "3D camera providing view and projection matrices",
OrbitCamera3D: "Camera that orbits around a target point",
MeshInstance3D: "Displays a 3D mesh with material",
Text3D: "Renders text as a 3D node in world space",
Path3D: "Holds a Curve3D for spline-based movement",
PathFollow3D: "Follows a parent Path3D curve by distance",
Marker3D: "Position marker for spawn points and waypoints (3D)",
# Physics 2D
CharacterBody2D: "2D body with move_and_slide for platformers",
RigidBody2D: "2D physics body affected by forces and gravity",
StaticBody2D: "2D immovable collision body for walls and floors",
KinematicBody2D: "2D body with manual movement and collision response",
Area2D: "2D trigger zone that detects overlapping bodies",
CollisionShape2D: "Defines collision geometry for a 2D physics body",
RayCast2D: "Persistent ray query for line-of-sight checks (2D)",
ShapeCast2D: "Swept shape query for ground/ledge detection (2D)",
# Physics 3D
CharacterBody3D: "3D body with move_and_slide for FPS/third-person",
RigidBody3D: "3D physics body affected by forces and gravity",
StaticBody3D: "3D immovable collision body for walls and floors",
KinematicBody3D: "3D body with manual movement and collision response",
Area3D: "3D trigger zone that detects overlapping bodies",
CollisionShape3D: "Defines collision geometry for a 3D physics body",
RayCast3D: "Persistent ray query for line-of-sight checks (3D)",
ShapeCast3D: "Swept shape query for ground/ledge detection (3D)",
PinJoint3D: "Physics joint that locks two bodies at a point",
HingeJoint3D: "Physics joint allowing rotation around one axis",
# Lights
DirectionalLight3D: "Infinite-range light casting parallel rays (sun)",
PointLight3D: "Omnidirectional light with range attenuation",
SpotLight3D: "Directional cone-shaped light",
PointLight2D: "Omnidirectional 2D light with range and falloff",
DirectionalLight2D: "Infinite-range 2D light casting parallel rays",
LightOccluder2D: "Casts 2D shadows by blocking light",
# Animation
AnimationPlayer: "Plays keyframe animations on node properties",
AnimationTree: "State machine for blending animations",
Skeleton: "Bone hierarchy for skeletal mesh animation",
# Audio
AudioStreamPlayer: "Plays audio in the background (non-positional)",
AudioStreamPlayer2D: "Plays positional audio in 2D space",
AudioStreamPlayer3D: "Plays spatial audio in 3D space",
# Navigation
NavigationAgent2D: "Steering agent that follows navigation paths (2D)",
NavigationAgent3D: "Steering agent that follows navigation paths (3D)",
NavigationObstacle3D: "Dynamic obstacle that navigation agents avoid",
# Particles
ParticleEmitter: "CPU particle system with emission shapes",
# TileMap
TileMap: "Grid-based tile rendering with auto-tiling",
# UI
Control: "Base class for all UI widgets",
Button: "Clickable button with text label",
Label: "Text display widget",
Panel: "Background container panel",
TextEdit: "Single-line text input field",
MultiLineTextEdit: "Multi-line text input with scroll support",
CheckBox: "Toggleable check box with label",
SpinBox: "Numeric input with increment/decrement buttons",
Slider: "Draggable slider for numeric range selection",
ProgressBar: "Horizontal bar showing a 0-1 progress value",
DropDown: "Drop-down menu for selecting from a list",
ColourPicker: "Interactive colour selection widget",
TreeView: "Hierarchical tree with expandable items",
RichTextLabel: "Label supporting bold, italic, and colour markup",
# Containers
HBoxContainer: "Arranges children horizontally",
VBoxContainer: "Arranges children vertically",
GridContainer: "Arranges children in a grid layout",
MarginContainer: "Adds configurable margins around a child",
ScrollContainer: "Scrollable viewport for oversized content",
SplitContainer: "Splits space between two children with a draggable divider",
TabContainer: "Tabbed container showing one child at a time",
# Viewport
SubViewport: "Renders a scene to an off-screen texture",
ViewportContainer: "Displays a SubViewport's texture in the scene",
WorldEnvironment: "Sets global environment (sky, fog, tonemap)",
# Misc
Node: "Base class for all scene tree objects",
Timer: "Triggers a timeout signal after a set duration",
}
def _get_inheritance_chain(cls: type) -> str:
"""Return a concise inheritance string like 'extends Node3D -> Node'."""
chain: list[str] = []
for base in cls.__mro__[1:]:
name = base.__name__
if name in ("object",):
break
chain.append(name)
if name == "Node":
break
return " \u2192 ".join(chain) if chain else ""
# ---------------------------------------------------------------------------
# Recently used types -- session-scoped, most-recent-first, max 8
# ---------------------------------------------------------------------------
_RECENT_TYPES: list[type] = []
_RECENT_MAX = 8
def _record_recent_type(cls: type):
"""Prepend *cls* to the recent-types list (dedup, cap at _RECENT_MAX)."""
if cls in _RECENT_TYPES:
_RECENT_TYPES.remove(cls)
_RECENT_TYPES.insert(0, cls)
if len(_RECENT_TYPES) > _RECENT_MAX:
_RECENT_TYPES[:] = _RECENT_TYPES[:_RECENT_MAX]
# ============================================================================
# Add Node Dialog -- overlay for choosing a node type to instantiate
# ============================================================================
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.visible = False
self.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT)
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)
self._rows: list[tuple[str, str, type | 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
# 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)
self.type_chosen = Signal() # Direct add (at default position)
self.type_place_chosen = Signal() # Place with mouse (Shift+click)
self._rebuild_rows()
# -- 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
# Reset collapse state: all collapsed except defaults
self._collapsed = {cat for cat, _ in _NODE_CATEGORIES if cat not in _DEFAULT_EXPANDED}
self._rebuild_rows()
self.visible = True
if self._tree:
self._tree.push_popup(self)
self._tree._set_focused_control(self._filter_edit)
def dismiss(self):
was_visible = self.visible
self.visible = False
self._hovered_index = -1
self._selected_index = -1
if was_visible and self._tree:
self._tree.pop_popup(self)
# -- popup overlay API (used by SceneTree input routing) --
def is_popup_point_inside(self, point) -> bool:
"""Return True if point is within the dialog bounds."""
if not self.visible:
return False
gx, gy, gw, gh = self.get_global_rect()
px = point.x if hasattr(point, "x") else point[0]
py = point.y if hasattr(point, "y") else point[1]
return gx <= px < gx + gw and gy <= py < gy + gh
def popup_input(self, event):
"""Handle a click routed through the popup system."""
py = event.position.y if hasattr(event.position, "y") else event.position[1]
gx, gy, gw, gh = self.get_global_rect()
list_y_start = gy + self.HEADER_HEIGHT
# Click in filter area -- route to filter edit
if py < list_y_start:
self._focus_in_list = False
if self._tree:
self._tree._set_focused_control(self._filter_edit)
return
# Click in list area
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":
# Toggle collapse
if name in self._collapsed:
self._collapsed.discard(name)
else:
self._collapsed.add(name)
self._rebuild_rows()
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:
_record_recent_type(cls)
self.type_chosen.emit(cls)
self.dismiss()
return
def dismiss_popup(self):
"""Called by SceneTree when clicking outside all popups."""
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:
_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) -> type | None:
"""Return the type class currently under keyboard selection or hover."""
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 _rebuild_rows(self):
"""Rebuild the flat row list from categories, respecting filter and collapse state."""
rows: list[tuple[str, str, type | None]] = []
ft = self._filter_text
if ft:
# Filter mode: show only matching types with their category headers, all expanded
# Include recent types that match filter
for cat_name, items in _NODE_CATEGORIES:
matches = [(name, cls) for name, cls in items if ft in name.lower()]
if matches:
rows.append(("category", cat_name, None))
for name, cls in matches:
rows.append(("type", name, cls))
else:
# Prepend "Recent" section if there are recent types and no 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, type]]:
"""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, and scroll."""
if not self.visible:
return
# Handle keyboard events (key presses only, not releases or char events)
if event.key and not event.button 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 not event.button:
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 draw(self, renderer):
"""Drawing handled by draw_popup (via SceneTree popup stack)."""
def draw_popup(self, renderer):
"""Draw the dialog as an overlay on top of everything."""
if not self.visible:
return
gx, gy, gw, gh = self.get_global_rect()
# Background + border
renderer.draw_filled_rect(gx, gy, gw, gh, self.bg_colour)
renderer.draw_rect_coloured(gx, gy, gw, gh, self.border_colour)
# Title and hint
renderer.draw_text_coloured("Add Node", gx + 6, gy + 2, self.font_size / 14.0, (0.7, 0.85, 1.0, 1.0))
renderer.draw_text_coloured("Shift = place", gx + gw - 80, gy + 4, 0.55, (0.5, 0.5, 0.5, 1.0))
# 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_filled_rect(gx + 1, row_y, gw - 2, rh, self.category_bg)
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_coloured(
f" {arrow} {name}", gx + 4, row_y + 4, scale, self.category_text
)
else:
# Type row (indented)
is_selected = (i == self._selected_index)
is_hovered = (i == self._hovered_index)
if is_selected:
renderer.draw_filled_rect(gx + 1, row_y, gw - 2, rh, self.select_colour)
elif is_hovered:
renderer.draw_filled_rect(gx + 1, row_y, gw - 2, rh, self.hover_colour)
icon = _NODE_ICONS.get(cls, "\u2295") if cls else "\u2295"
renderer.draw_text_coloured(f" {icon} {name}", gx + 4, row_y + 3, scale, self.text_colour)
renderer.pop_clip()
# -- Scroll indicator --
geom = self._scroll_indicator_geometry()
if geom:
sx, sy, sw, sh = geom
renderer.draw_filled_rect(sx, sy, sw, sh, self.scrollbar_colour)
# -- Description footer --
footer_y = gy + gh - self.FOOTER_HEIGHT
renderer.draw_filled_rect(gx, footer_y, gw, self.FOOTER_HEIGHT, self.footer_bg)
renderer.draw_line_coloured(gx, footer_y, gx + gw, footer_y, self.footer_sep)
focused = self._focused_type()
if focused:
type_name = focused.__name__
chain = _get_inheritance_chain(focused)
desc = _NODE_DESCRIPTIONS.get(focused, "")
title_text = f"{type_name} extends {chain}" if chain else type_name
renderer.draw_text_coloured(title_text, gx + 6, footer_y + 6, scale * 0.95, self.text_colour)
if desc:
renderer.draw_text_coloured(desc, gx + 6, footer_y + 24, scale * 0.85, self.desc_colour)
# ============================================================================
# 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 draw(self, renderer):
if not self.visible:
return
# The child TextEdit handles its own drawing
# ============================================================================
# SceneTreePanel -- main panel
# ============================================================================
[docs]
class SceneTreePanel(Control):
"""Displays and manages the node hierarchy of the edited scene.
Features:
- TreeView reflecting scene node hierarchy
- Text icons per node type
- Filter bar in the header
- Right-click context menu (Add, Delete, Duplicate, Rename, Copy, Paste)
- Keyboard shortcuts (Del, F2, Ctrl+D, Ctrl+C, Ctrl+V)
- All mutations go through UndoStack
- Syncs with EditorState.selection
- Add Node dialog overlay for choosing node type
Signals:
node_reparented: Emitted as (node, old_parent, new_parent) after reparent.
"""
HEADER_HEIGHT = 28.0
node_reparented = Signal()
def __init__(self, editor_state: EditorState, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.size = Vec2(260, 500)
# Colours
self._header_bg = (0.11, 0.11, 0.14, 1.0)
self._header_text = (0.82, 0.87, 0.97, 1.0)
self._bg_colour = (0.08, 0.08, 0.10, 1.0)
# -- "+" button for quick Add Node access --
self._add_btn = Button("+", name="AddNodeBtn")
self._add_btn.size = Vec2(24, 22)
self._add_btn.font_size = 14.0
self._add_btn.pressed.connect(self._ctx_add_node)
self.add_child(self._add_btn)
# -- Header filter --
self._filter_edit = TextEdit(placeholder="Filter...", name="TreeFilter")
self._filter_edit.size = Vec2(100, 22)
self._filter_edit.font_size = 12.0
self._filter_edit.text_changed.connect(self._on_filter_changed)
self.add_child(self._filter_edit)
# -- TreeView --
self._tree_view = TreeView(name="SceneTree")
self._tree_view.size = Vec2(260, 470)
self._tree_view.bg_colour = self._bg_colour
self._tree_view.item_selected.connect(self._on_tree_item_selected)
self.add_child(self._tree_view)
# -- Right-click context menu --
self._context_menu = PopupMenu(
items=[
MenuItem("Add Node", callback=self._ctx_add_node),
MenuItem(separator=True),
MenuItem("Rename", callback=self._ctx_rename, shortcut="F2"),
MenuItem("Duplicate", callback=self._ctx_duplicate, shortcut="Ctrl+D"),
MenuItem("Delete", callback=self._ctx_delete, shortcut="Del"),
MenuItem(separator=True),
MenuItem("Copy", callback=self._ctx_copy, shortcut="Ctrl+C"),
MenuItem("Paste", callback=self._ctx_paste, shortcut="Ctrl+V"),
]
)
self.add_child(self._context_menu)
# -- Add Node dialog --
self._add_dialog = _AddNodeDialog(name="AddNodeDialog")
self._add_dialog.type_chosen.connect(self._on_add_type_chosen)
self._add_dialog.type_place_chosen.connect(self._on_place_type_chosen)
self.add_child(self._add_dialog)
# -- Rename overlay --
self._rename_overlay = _RenameOverlay(name="RenameOverlay")
self._rename_overlay.rename_confirmed.connect(self._on_rename_confirmed)
self.add_child(self._rename_overlay)
# Filter state
self._filter_text = ""
self._filter_match_cache: dict[int, bool] = {}
self._filter_debounce_pending = False
self._filter_debounce_timer = 0.0
# Track the right-clicked item for context menu actions
self._context_item: TreeItem | None = None
# Prevent re-entrant selection sync
self._syncing_selection = False
# Diagnostic tracking -- node ids with script errors
self._error_nodes: set[int] = set()
self._warning_nodes: set[int] = set()
# ------------------------------------------------------------------- ready
[docs]
def ready(self):
"""Wire up signals after the node enters the tree."""
self._rebuild_tree()
self.state.scene_changed.connect(self._rebuild_tree)
self.state.selection_changed.connect(self._on_selection_changed)
self.state.add_node_requested.connect(self._ctx_add_node)
Node.script_error_raised.connect(self._on_script_error)
# --------------------------------------------------------- tree building
def _rebuild_tree(self):
"""Rebuild the TreeView from the scene root."""
root_node = self.state.edited_scene.root if self.state.edited_scene else None
if not root_node:
self._tree_view.root = None
return
# Pre-compute filter matches in a single O(n) pass
self._filter_match_cache: dict[int, bool] = {}
if self._filter_text:
self._matches_filter(root_node, self._filter_match_cache)
self._tree_view.root = self._build_tree_item(root_node)
# Re-select current selection in the tree
self._sync_tree_selection()
def _build_tree_item(self, node: Node) -> TreeItem:
"""Recursively create a TreeItem hierarchy from a Node hierarchy."""
icon = _get_node_icon(node)
badge = " \u2699" if node.script else ""
nid = id(node)
if nid in self._error_nodes:
badge += " \u26a0" # warning sign for errors
elif nid in self._warning_nodes:
badge += " \u25cb" # circle for warnings
display = f"{icon} {node.name}{badge}"
item = TreeItem(display, data=node)
for child in node.children:
if self._filter_text:
if self._filter_match_cache.get(id(child), False):
item.add_child(self._build_tree_item(child))
else:
item.add_child(self._build_tree_item(child))
return item
def _matches_filter(self, node: Node, cache: dict[int, bool]) -> bool:
"""Return True if *node* or any descendant matches the filter text.
Results are memoized in *cache* to avoid O(n^2) re-traversal.
"""
nid = id(node)
if nid in cache:
return cache[nid]
if self._filter_text in node.name.lower():
cache[nid] = True
return True
for child in node.children:
if self._matches_filter(child, cache):
cache[nid] = True
return True
cache[nid] = False
return False
def _on_filter_changed(self, text: str):
self._filter_text = text.strip().lower()
# Debounce: delay rebuild until typing pauses (100ms)
self._filter_debounce_pending = True
self._filter_debounce_timer = 0.1
def _flush_filter(self):
"""Force any pending debounced filter rebuild to execute immediately."""
if self._filter_debounce_pending:
self._filter_debounce_pending = False
self._rebuild_tree()
# --------------------------------------------------------- diagnostics
def _on_script_error(self, node: Node, method: str, traceback_str: str):
"""Mark a node as having a script error and refresh the tree."""
self._error_nodes.add(id(node))
self._rebuild_tree()
[docs]
def clear_diagnostics(self, node: Node | None = None):
"""Clear diagnostic badges. If *node* is given, clear just that node."""
if node is not None:
self._error_nodes.discard(id(node))
self._warning_nodes.discard(id(node))
else:
self._error_nodes.clear()
self._warning_nodes.clear()
self._rebuild_tree()
# ---------------------------------------------------------- selection sync
def _on_tree_item_selected(self, item: TreeItem):
"""When the user selects a tree item, update EditorState.selection."""
if self._syncing_selection:
return
node = item.data if item else None
if node:
self._syncing_selection = True
self.state.selection.select(node)
self._syncing_selection = False
def _on_selection_changed(self):
"""When EditorState.selection changes externally, update the tree."""
if self._syncing_selection:
return
self._sync_tree_selection()
def _sync_tree_selection(self):
"""Walk the tree to find and select the item whose data matches
the current primary selection."""
primary = self.state.selection.primary
if not primary or not self._tree_view.root:
self._tree_view.selected = None
return
found = self._find_item_by_data(self._tree_view.root, primary)
if found:
self._syncing_selection = True
self._tree_view.selected = found
# Ensure ancestors are expanded so the item is visible
self._expand_ancestors(found)
self._syncing_selection = False
def _find_item_by_data(self, item: TreeItem, data) -> TreeItem | None:
"""Depth-first search for a TreeItem whose .data is *data*."""
if item.data is data:
return item
for child in item.children:
result = self._find_item_by_data(child, data)
if result is not None:
return result
return None
@staticmethod
def _expand_ancestors(item: TreeItem):
"""Expand all ancestor items so *item* is visible in the tree."""
parent = item.parent
while parent is not None:
parent.expanded = True
parent = parent.parent
# -------------------------------------------------------- context menu
def _on_gui_input(self, event):
"""Handle right-click context menu and keyboard shortcuts."""
# Right-click opens context menu
if event.button == 3 and event.pressed:
self._context_item = self._tree_view.selected
px = (
event.position[0]
if isinstance(event.position, tuple | list)
else (event.position.x if hasattr(event.position, "x") else 0)
)
py = (
event.position[1]
if isinstance(event.position, tuple | list)
else (event.position.y if hasattr(event.position, "y") else 0)
)
self._context_menu.show(px, py)
return
# Keyboard shortcuts (key-up events)
if event.key and not event.pressed:
if event.key == "delete":
self._ctx_delete()
return
if event.key == "f2":
self._ctx_rename()
return
# Dismiss rename overlay if clicking outside
if event.button == 1 and event.pressed:
if self._rename_overlay.visible:
rx, ry, rw, rh = self._rename_overlay.get_global_rect()
px = (
event.position[0]
if isinstance(event.position, tuple | list)
else (event.position.x if hasattr(event.position, "x") else 0)
)
py = (
event.position[1]
if isinstance(event.position, tuple | list)
else (event.position.y if hasattr(event.position, "y") else 0)
)
if not (rx <= px <= rx + rw and ry <= py <= ry + rh):
self._rename_overlay.cancel()
# -------------------------------------------------------- context actions
def _selected_node(self) -> Node | None:
"""Return the node referenced by the right-clicked or selected item."""
item = self._context_item or self._tree_view.selected
return item.data if item else None
[docs]
def open_add_node_dialog(self):
"""Open the Add Node type dialog. Can be called externally (e.g. from menus)."""
self._ctx_add_node()
def _ctx_add_node(self):
"""Open the Add Node dialog."""
gx, gy, _, _ = self.get_global_rect()
self._add_dialog.show_at(gx + 30, gy + 60)
def _on_add_type_chosen(self, node_class: type):
"""Create a new node of *node_class* under the selected node."""
_record_recent_type(node_class)
# Use selection primary as source of truth (tree_view.selected may lag)
parent = self.state.selection.primary or self._selected_node()
if parent is None:
parent = self.state.edited_scene.root if self.state.edited_scene else None
if parent is None:
return
new_node = node_class(name=node_class.__name__)
cmd = CallableCommand(
lambda p=parent, n=new_node: p.add_child(n),
lambda p=parent, n=new_node: p.remove_child(n),
f"Add {new_node.name}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
self._rebuild_tree()
self.state.selection.select(new_node)
def _on_place_type_chosen(self, node_class: type):
"""Enter mouse-placement mode for the chosen type.
The user will click in the 2D viewport to place the node at that position.
"""
self.state.enter_place_mode(node_class)
def _ctx_delete(self):
"""Delete the selected node (with undo)."""
node = self._selected_node()
if node is None:
return
parent = node.parent
if parent is None:
return # Cannot delete root
# Capture sibling index for undo reinsertion at original position
idx = list(parent.children).index(node) if node in parent.children else -1
def do_delete(p=parent, n=node):
p.remove_child(n)
def undo_delete(p=parent, n=node, i=idx):
p.add_child(n)
# Restore original position in the Children._list
if i >= 0 and i < len(p.children._list):
p.children._list.remove(n)
p.children._list.insert(i, n)
p.children._dirty = True
cmd = CallableCommand(do_delete, undo_delete, f"Delete {node.name}")
self.state.undo_stack.push(cmd)
self.state.selection.clear()
self.state.modified = True
self._rebuild_tree()
def _ctx_duplicate(self):
"""Duplicate the selected node (with undo)."""
node = self._selected_node()
if node is None:
return
clone = self.state.duplicate_node(node)
if clone:
self._rebuild_tree()
self.state.selection.select(clone)
def _ctx_rename(self):
"""Show the inline rename overlay for the selected node."""
node = self._selected_node()
if node is None:
return
# Position the overlay roughly where the item is in the tree
item = self._tree_view.selected
if item is None:
return
# Find row position from tree's row map
gx, gy, _, _ = self._tree_view.get_global_rect()
row_y = gy
for map_item, _, my, _ in self._tree_view._row_map:
if map_item is item:
row_y = my
break
self._rename_overlay.begin(node, gx + 30, row_y)
def _on_rename_confirmed(self, node: Node, new_name: str):
"""Apply rename through the undo stack."""
old_name = node.name
def do_rename(n=node, nn=new_name):
n.name = nn
def undo_rename(n=node, on=old_name):
n.name = on
cmd = CallableCommand(do_rename, undo_rename, f"Rename '{old_name}' -> '{new_name}'")
self.state.undo_stack.push(cmd)
self.state.modified = True
self._rebuild_tree()
def _ctx_copy(self):
"""Copy the selected node to the clipboard."""
node = self._selected_node()
if node is None:
return
self.state.clipboard.copy_node(node)
def _ctx_paste(self):
"""Paste a node from the clipboard under the selected node."""
if not self.state.clipboard.has_node():
return
parent = self._selected_node()
if parent is None:
parent = self.state.edited_scene.root if self.state.edited_scene else None
if parent is None:
return
new_node = self.state.clipboard.paste_node()
if new_node is None:
return
new_node.name = f"{new_node.name}_paste"
cmd = CallableCommand(
lambda p=parent, n=new_node: p.add_child(n),
lambda p=parent, n=new_node: p.remove_child(n),
f"Paste {new_node.name}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
self._rebuild_tree()
self.state.selection.select(new_node)
# -------------------------------------------------------- reparenting
[docs]
def reparent_node(self, node: Node, new_parent: Node):
"""Reparent *node* under *new_parent* with undo support."""
old_parent = node.parent
if old_parent is None or old_parent is new_parent:
return
cmd = CallableCommand(
lambda n=node, np=new_parent: n.reparent(np),
lambda n=node, op=old_parent: n.reparent(op),
f"Reparent {node.name}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
self._rebuild_tree()
self.node_reparented.emit(node, old_parent, new_parent)
# ------------------------------------------------------------- layout
[docs]
def process(self, dt: float):
"""Update child positions each frame to follow panel size."""
# Debounced filter rebuild
if getattr(self, "_filter_debounce_pending", False):
self._filter_debounce_timer -= dt
if self._filter_debounce_timer <= 0:
self._filter_debounce_pending = False
self._rebuild_tree()
gx, gy, gw, gh = self.get_global_rect()
# Position "+" button at the right end of the header
btn_x = gw - 30
self._add_btn.position = Vec2(btn_x, 3)
# Position the filter edit in the header area (left of the "+" button)
filter_w = min(100, btn_x - 80)
self._filter_edit.position = Vec2(btn_x - filter_w - 6, 3)
self._filter_edit.size = Vec2(max(60, filter_w), 22)
# Position tree below header
self._tree_view.position = Vec2(0, self.HEADER_HEIGHT)
self._tree_view.size = Vec2(gw, gh - self.HEADER_HEIGHT)
# ---------------------------------------------------------------- draw
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Panel background
renderer.draw_filled_rect(x, y, w, h, self._bg_colour)
# Header bar
renderer.draw_filled_rect(x, y, w, self.HEADER_HEIGHT, self._header_bg)
# Header label
scale = 13.0 / 14.0
renderer.draw_text_coloured("Scene Tree", x + 6, y + 6, scale, self._header_text)
# Bottom border of header
renderer.draw_line_coloured(x, y + self.HEADER_HEIGHT, x + w, y + self.HEADER_HEIGHT, (0.32, 0.32, 0.36, 1.0))