Source code for simvx.editor.panels.scene_tree

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