"""Scene Tree Panel -- main hierarchical view with selection, context menu, add/delete."""
import fnmatch
import logging
import re
from typing import TYPE_CHECKING
from simvx.core import (
# Animation
Button,
CallableCommand,
Control,
# 2D Lights
MenuItem,
Node,
PopupMenu,
Signal,
TextEdit,
# TileMap
TreeItem,
TreeView,
Vec2,
)
from simvx.editor.commands import AddNodeCommand, RemoveNodeCommand
from simvx.editor.project_classes import ProjectClass, ProjectClassIndex
from .dialogs import _AddNodeDialog, _RenameOverlay
from .type_registry import (
_get_node_icon,
_get_source_file,
_record_recent_type,
is_composed_node,
register_addable_type,
)
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from simvx.editor.state import State
__all__ = ["SceneTreePanel", "register_addable_type", "is_composed_node"]
# ============================================================================
[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 via AddNodeCommand/RemoveNodeCommand
- Syncs with State.selection
- Add Node dialog overlay for choosing node type
- Composed node navigation: double-click or Enter to "enter" a user-defined
node and see its children; breadcrumb/back button to return
Signals:
node_reparented: Emitted as (node, old_parent, new_parent) after reparent.
"""
HEADER_HEIGHT = 28.0
BREADCRUMB_HEIGHT = 22.0
node_reparented = Signal()
def __init__(self, editor_state: State, **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._bg_colour = (0.08, 0.08, 0.10, 1.0)
self._breadcrumb_bg = (0.13, 0.13, 0.16, 1.0)
self._breadcrumb_text = (0.65, 0.75, 0.95, 1.0)
self._breadcrumb_hover = (0.75, 0.85, 1.0, 1.0)
self._composed_indicator = (0.55, 0.70, 0.90, 1.0)
# -- Composed node navigation stack --
# When the user "enters" a composed node, we push the node onto this stack.
# The tree then shows children of the entered node as the root scope.
self._scope_stack: list[Node] = []
# -- Back button for composed node navigation --
self._back_btn = Button("\u2190", name="BackBtn")
self._back_btn.size = Vec2(24, 22)
self._back_btn.font_size = 14.0
self._back_btn.pressed.connect(self._navigate_back)
self._back_btn.visible = False
self.add_child(self._back_btn)
# -- "+" 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 --
# Filter modes: substring (literal contains, case-insensitive), glob
# (fnmatch.fnmatchcase against lowered name), regex (re.search,
# case-insensitive). Single button cycles through the three modes.
self._filter_modes: tuple[str, ...] = ("substring", "glob", "regex")
self._filter_mode: str = "substring"
self._filter_regex_invalid: bool = False
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)
# Mode-cycle button: shows the active mode's short tag (e.g. "abc",
# "*?", ".*"). One button, one canonical way -- not separate toggles.
self._filter_mode_btn = Button(self._mode_button_label(), name="FilterModeBtn")
self._filter_mode_btn.font_size = 11.0
self._filter_mode_btn.pressed.connect(self._cycle_filter_mode)
self.add_child(self._filter_mode_btn)
# Clear button: clears the filter input and refreshes the tree.
self._filter_clear_btn = Button("X", name="FilterClearBtn")
self._filter_clear_btn.font_size = 11.0
self._filter_clear_btn.pressed.connect(self._clear_filter)
self.add_child(self._filter_clear_btn)
# -- 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("Make Custom Class", callback=self._ctx_make_custom_class),
MenuItem("Rename Class...", callback=self._ctx_rename_class),
MenuItem("Open as Scene", callback=self._ctx_open_as_scene),
MenuItem(separator=True),
MenuItem("Rename", callback=self._ctx_rename, shortcut="F2"),
MenuItem("Duplicate", callback=self._ctx_duplicate, shortcut="Ctrl+D"),
MenuItem("Duplicate...", callback=self._ctx_duplicate_dialog),
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)
# -- Project class index (drives the Add Node dialog's user-class picker) --
# Initial path is best-effort; ready() refreshes once state.project_path
# is known. The index also re-binds whenever the scene changes.
project_path = getattr(editor_state, "project_path", None)
self._project_class_index = ProjectClassIndex(project_path)
# -- Add Node dialog --
self._add_dialog = _AddNodeDialog(name="AddNodeDialog")
self._add_dialog.set_project_index(self._project_class_index)
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
self._filter_regex: re.Pattern[str] | None = None
# 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()
# Parsed traceback location per errored node so badge clicks can
# navigate the script workspace to the offending file+line.
self._error_sources: dict[int, tuple[str, int]] = {}
self._tree_view.item_badge_clicked.connect(self._on_item_badge_clicked)
# ------------------------------------------------------------------- ready
[docs]
def ready(self):
"""Wire up signals after the node enters the tree."""
self._rebuild_tree()
self._sync_project_index()
self.state.scene_changed.connect(self._on_scene_changed)
self.state.selection_changed.connect(self._on_selection_changed)
self.state.add_node_requested.connect(self._ctx_add_node)
if hasattr(self.state, "play_state_changed"):
self.state.play_state_changed.connect(self._on_play_state_changed)
Node.script_error_raised.connect(self._on_script_error)
def _sync_project_index(self):
"""Re-bind the project-class index to the current ``state.project_path``.
Cheap when unchanged. Called from ``ready`` and on scene changes since
loading a scene may switch projects.
"""
path = getattr(self.state, "project_path", None)
self._project_class_index.set_project_path(path)
def _on_scene_changed(self):
"""Handle scene changes -- reset scope stack and rebuild."""
self._scope_stack.clear()
self._back_btn.visible = False
self._sync_project_index()
self._rebuild_tree()
def _on_play_state_changed(self):
"""Switch between edited scene and game tree on play/stop."""
self._scope_stack.clear()
self._back_btn.visible = False
self._rebuild_tree()
# ------------------------------------------------ composed node navigation
[docs]
@property
def scope_root(self) -> Node | None:
"""The node whose children are currently displayed in the tree.
During play mode, shows the game tree root (read-only view).
When the scope stack is empty, this is the scene root.
When the user has "entered" a composed node, this is that node.
"""
# During play mode, show the game tree
play_mode = getattr(self.state, "play_mode", None)
if getattr(self.state, "is_playing", False) and play_mode is not None:
gt = play_mode.game_tree
if gt is not None:
return gt.root
if self._scope_stack:
return self._scope_stack[-1]
return self.state.edited_scene.root if self.state.edited_scene else None
[docs]
@property
def scope_depth(self) -> int:
"""How many levels deep we are into composed nodes."""
return len(self._scope_stack)
[docs]
def enter_composed_node(self, node: Node):
"""Navigate into a composed node, showing its children as the tree root.
If the node has a source file, also opens it in the editor via
``state.workspace.open_script()``.
"""
if not is_composed_node(node):
return
self._scope_stack.append(node)
self._back_btn.visible = True
self._rebuild_tree()
# Open source file if available
source = _get_source_file(node)
if source and hasattr(self.state, "workspace"):
try:
self.state.workspace.open_script(node, project_path_fn=lambda: self.state.project_path)
except Exception:
# justified: script-file open is best-effort; failing to open shouldn't block tree navigation
pass
def _navigate_back(self):
"""Return to the parent scope after entering a composed node."""
if self._scope_stack:
self._scope_stack.pop()
self._back_btn.visible = bool(self._scope_stack)
self._rebuild_tree()
def _breadcrumb_path(self) -> list[tuple[str, Node | None]]:
"""Build a breadcrumb trail from the scene root through the scope stack.
Returns a list of (label, node_or_none) tuples. The last entry is the
current scope (node=None means "not clickable").
"""
result: list[tuple[str, Node | None]] = []
root = self.state.edited_scene.root if self.state.edited_scene else None
if root:
result.append((root.name, root if self._scope_stack else None))
for i, node in enumerate(self._scope_stack):
is_last = (i == len(self._scope_stack) - 1)
result.append((node.name, None if is_last else node))
return result
# --------------------------------------------------------- tree building
def _rebuild_tree(self):
"""Rebuild the TreeView from the current scope root."""
root_node = self.scope_root
if not root_node:
self._tree_view.root = None
return
# If the user typed an invalid regex, leave the tree exactly as-is so
# the prior match set is preserved while they finish typing. The red
# border on the filter input signals the parse error.
if self._filter_regex_invalid:
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()
# Scroll the first matching row into view (do not change selection).
if self._filter_text:
self._scroll_to_first_match()
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 ""
composed = is_composed_node(node)
if composed:
badge += " \u25b8" # right-pointing triangle = "enterable"
display = f"{icon} {node.name}{badge}"
item = TreeItem(display, data=node)
# Error/warning state is surfaced as a clickable trailing badge so
# clicking the icon jumps straight to the error source (wired in
# SceneTreePanel via TreeView.item_badge_clicked).
nid = id(node)
if nid in self._error_nodes:
item.badge_text = "\u26a0" # warning triangle
src = self._error_sources.get(nid)
if src is not None:
file_path, line_num = src
item.badge_tooltip = f"{file_path}:{line_num}"
else:
item.badge_tooltip = "Script error (click to open)"
elif nid in self._warning_nodes:
item.badge_text = "\u25cb"
item.badge_tooltip = "Warning"
# For composed nodes, still show their children but start collapsed
# so the user can expand inline or double-click to enter
if composed and node.children:
item.expanded = False
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 _name_matches(self, name_lower: str) -> bool:
"""Test a single (already-lowered) node name against the active filter."""
if not self._filter_text:
return True
mode = self._filter_mode
if mode == "substring":
return self._filter_text in name_lower
if mode == "glob":
return fnmatch.fnmatchcase(name_lower, self._filter_text)
if mode == "regex":
pat = self._filter_regex
return pat is not None and pat.search(name_lower) is not None
return False
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. When a
node matches by name, all of its descendants are marked visible so the
whole subtree shows -- not just the matching ancestor.
"""
nid = id(node)
if nid in cache:
return cache[nid]
if self._name_matches(node.name.lower()):
for descendant in node.walk():
cache[id(descendant)] = True
return True
any_child_matches = False
for child in node.children:
# Visit every child unconditionally so its subtree is cached;
# short-circuiting with any() would hide siblings of cousin matches.
if self._matches_filter(child, cache):
any_child_matches = True
cache[nid] = any_child_matches
return any_child_matches
def _on_filter_changed(self, text: str):
self._filter_text = text.strip().lower()
self._recompile_regex()
# 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()
# ------------------------------------------------ filter modes / clear
def _mode_button_label(self) -> str:
"""Short tag rendered on the mode-cycle button."""
return {"substring": "abc", "glob": "*?", "regex": ".*"}[self._filter_mode]
def _cycle_filter_mode(self):
"""Advance to the next filter mode and refresh the tree."""
idx = self._filter_modes.index(self._filter_mode)
self._filter_mode = self._filter_modes[(idx + 1) % len(self._filter_modes)]
self._filter_mode_btn.text = self._mode_button_label()
self._recompile_regex()
self._flush_filter()
self._rebuild_tree()
def _clear_filter(self):
"""Clear the filter text and rebuild the tree showing all nodes."""
self._filter_edit.text = ""
self._filter_edit.cursor_pos = 0
self._filter_text = ""
self._filter_regex = None
self._filter_regex_invalid = False
self._filter_debounce_pending = False
self._rebuild_tree()
def _recompile_regex(self):
"""Re-parse the regex pattern for the current text+mode.
Only catches ``re.error`` -- any other exception is a real bug and
should propagate (per feedback_strict_errors).
"""
if self._filter_mode != "regex" or not self._filter_text:
self._filter_regex = None
self._filter_regex_invalid = False
return
try:
self._filter_regex = re.compile(self._filter_text, re.IGNORECASE)
except re.error:
self._filter_regex = None
self._filter_regex_invalid = True
else:
self._filter_regex_invalid = False
def _scroll_to_first_match(self):
"""Scroll the TreeView so the first matching row is visible.
Does not change selection. No-op when there is no match or the
TreeView has no measured viewport yet.
"""
tv = self._tree_view
# Force the flat row cache so we can index into _flat_rows directly.
tv._ensure_flat_rows()
rows = tv._flat_rows or []
if not rows:
return
for index, (item, _depth) in enumerate(rows):
node = item.data
if node is None:
continue
if self._name_matches(node.name.lower()):
target_y = index * tv.row_height
_, _, _, view_h = tv.get_global_rect()
if view_h <= 0:
# Viewport not measured yet -- still nudge scroll so the
# match isn't above the visible area on the next draw.
tv._scroll_y = target_y
else:
visible_top = tv._scroll_y
visible_bottom = visible_top + view_h - tv.row_height
if target_y < visible_top:
tv._scroll_y = target_y
elif target_y > visible_bottom:
tv._scroll_y = target_y - view_h + tv.row_height
tv.queue_redraw()
return
# --------------------------------------------------------- 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."""
from ...error_nav import parse_traceback_source
nid = id(node)
self._error_nodes.add(nid)
project_path = getattr(self.state, "project_path", None) if self.state else None
source = parse_traceback_source(traceback_str, project_path)
if source is not None:
self._error_sources[nid] = source
self._rebuild_tree()
def _on_item_badge_clicked(self, item):
"""Navigate to the error source for the clicked badge."""
node = item.data
if node is None:
return
src = self._error_sources.get(id(node))
if src is None:
return
file_path, line_num = src
workspace = getattr(self.state, "workspace", None) if self.state else None
if workspace is None:
return
if hasattr(workspace, "open_file"):
workspace.open_file(file_path, line=line_num)
[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:
nid = id(node)
self._error_nodes.discard(nid)
self._warning_nodes.discard(nid)
self._error_sources.pop(nid, None)
else:
self._error_nodes.clear()
self._warning_nodes.clear()
self._error_sources.clear()
self._rebuild_tree()
# ---------------------------------------------------------- selection sync
def _on_tree_item_selected(self, item: TreeItem):
"""When the user selects a tree item, update State.selection.
Supports Ctrl+Click (additive toggle) and Shift+Click (range select).
"""
if self._syncing_selection:
return
node = item.data if item else None
if not node:
return
self._syncing_selection = True
if self._is_ctrl_held():
# Ctrl+Click: toggle selection of this node
self.state.selection.select(node, additive=True)
elif self._is_shift_held():
# Shift+Click: range select from last primary to this node
anchor = self.state.selection.primary
if anchor is not None:
flat = self._flat_node_list()
self.state.selection.select_range(anchor, node, flat)
else:
self.state.selection.select(node)
else:
self.state.selection.select(node)
self._syncing_selection = False
def _on_selection_changed(self):
"""When State.selection changes externally, update the tree."""
if self._syncing_selection:
return
self._sync_tree_selection()
@staticmethod
def _is_ctrl_held() -> bool:
"""Check if Ctrl is currently held via the Input key state."""
from simvx.core import Input
return Input._keys.get("ctrl", False)
@staticmethod
def _is_shift_held() -> bool:
"""Check if Shift is currently held via the Input key state."""
from simvx.core import Input
return Input._keys.get("shift", False)
def _flat_node_list(self) -> list:
"""Return all scene nodes in tree-traversal (DFS) order for range selection."""
root = self.state.edited_scene.root if self.state.edited_scene else None
if root is None:
return []
result: list = []
self._collect_flat(root, result)
return result
@staticmethod
def _collect_flat(node, out: list):
out.append(node)
for child in node.children:
SceneTreePanel._collect_flat(child, out)
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)
# Scroll to make the selected item visible
if hasattr(self._tree_view, "ensure_item_visible"):
self._tree_view.ensure_item_visible(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, keyboard shortcuts, and composed node navigation."""
# 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
# Double-click on a composed node enters it
if getattr(event, "double_click", False) and event.button == 1:
node = self._selected_node()
if node and is_composed_node(node):
self.enter_composed_node(node)
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
# Enter key on a composed node navigates into it
if event.key in ("enter", "return"):
node = self._selected_node()
if node and is_composed_node(node):
self.enter_composed_node(node)
return
# Backspace navigates back when in a composed node scope
if event.key == "backspace" and self._scope_stack:
self._navigate_back()
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 _is_play_mode_active(self) -> bool:
"""Return True if the editor is in play mode (mutations disabled)."""
return getattr(self.state, "is_playing", False)
def _ctx_add_node(self):
"""Open the Add Node dialog."""
if self._is_play_mode_active():
return
gx, gy, _, _ = self.get_global_rect()
self._add_dialog.show_at(gx + 30, gy + 60)
def _resolve_class(self, entry: object) -> type | None:
"""Resolve a dialog entry (built-in ``type`` or ``ProjectClass``) to a live class.
Returns ``None`` if a project class fails to import; the caller logs
the failure and aborts the Add Node operation rather than crashing.
"""
if isinstance(entry, type):
return entry
if isinstance(entry, ProjectClass):
return self._project_class_index.resolve(entry)
return None
def _on_add_type_chosen(self, entry: object):
"""Create a new node of *entry*'s class under the selected node.
``entry`` is either a built-in ``type`` or a :class:`ProjectClass`
record produced by the dialog. Add Node is **instance-only**: no file
or class is generated -- the user invoked Add Node to drop an instance
of an existing class into the tree.
"""
node_class = self._resolve_class(entry)
if node_class is None:
log.warning("Add Node: failed to resolve %r", entry)
return
if isinstance(entry, type):
_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.scope_root
if parent is None:
return
new_node = node_class(name=node_class.__name__)
cmd = AddNodeCommand(parent, new_node)
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, entry: object):
"""Enter mouse-placement mode for the chosen entry's class.
The user will click in the 2D viewport to place the node at that position.
"""
node_class = self._resolve_class(entry)
if node_class is None:
log.warning("Place Node: failed to resolve %r", entry)
return
self.state.enter_place_mode(node_class)
def _ctx_open_as_scene(self):
"""Open the selected node as a new scene tab."""
node = self._selected_node()
if node is None:
return
from simvx.editor.workspace_tabs import SceneTabState
tab = SceneTabState.create(root_type=type(node), name=node.name)
self.state.workspace.add_scene_tab(tab)
self.state.scene_changed.emit()
def _ctx_delete(self):
"""Delete the selected node (with undo)."""
if self._is_play_mode_active():
return
node = self._selected_node()
if node is None:
return
if node.parent is None:
return # Cannot delete root
cmd = RemoveNodeCommand(node.parent, node)
self.state.undo_stack.push(cmd)
self.state.selection.clear()
self.state.modified = True
self._rebuild_tree()
def _ctx_duplicate(self):
"""Quick duplicate (Ctrl+D) — adds another instance of the same class."""
if self._is_play_mode_active():
return
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_duplicate_dialog(self):
"""Open the Duplicate Node dialog (New Instance / Subclass / Detached Copy)."""
if self._is_play_mode_active():
return
node = self._selected_node()
if node is None or node.parent is None:
return
dialog = self._ensure_duplicate_dialog()
if dialog is None:
return
project_path = getattr(self.state, "project_path", None)
settings = getattr(self.state, "settings", None)
cfd = "src"
if settings is not None and getattr(settings, "editor", None) is not None:
cfd = settings.editor.get("class_files_dir", "src")
dialog.show_for(
node,
project_index=self._project_class_index,
project_path=project_path,
class_files_dir=cfd,
)
def _ctx_make_custom_class(self):
"""Open the Make Custom Class dialog for a built-in selection."""
if self._is_play_mode_active():
return
node = self._selected_node()
if node is None or not self._is_builtin_class(type(node)):
return
dialog = self._ensure_make_custom_class_dialog()
if dialog is None:
return
if getattr(dialog, "_project_index", None) is None and hasattr(dialog, "set_project_index"):
dialog.set_project_index(self._project_class_index)
dialog.show_for(node)
def _ctx_rename_class(self):
"""Open the Rename Class dialog for a user-class selection."""
if self._is_play_mode_active():
return
node = self._selected_node()
if node is None or self._is_builtin_class(type(node)):
return
dialog = self._ensure_rename_class_dialog()
if dialog is None:
return
dialog.show_for(
node,
project_index=self._project_class_index,
on_renamed=lambda _result: self._rebuild_tree(),
)
def _ensure_rename_class_dialog(self):
state = getattr(self, "state", None)
if state is None:
return None
dialog = getattr(state, "_rename_class_dialog", None)
if dialog is None:
from ..rename_class_dialog import RenameClassDialog
dialog = RenameClassDialog(name="RenameClassDialog")
state._rename_class_dialog = dialog
root = self.find_root() if hasattr(self, "find_root") else None
host = root if isinstance(root, Node) else self
host.add_child(dialog)
return dialog
@staticmethod
def _is_builtin_class(cls) -> bool:
return getattr(cls, "__module__", "").startswith("simvx.core")
def _ensure_make_custom_class_dialog(self):
state = getattr(self, "state", None)
if state is None:
return None
dialog = getattr(state, "_make_custom_class_dialog", None)
if dialog is None:
from simvx.editor.make_custom_class_dialog import MakeCustomClassDialog
dialog = MakeCustomClassDialog(state=state, name="MakeCustomClassDialog")
state._make_custom_class_dialog = dialog
root = self.find_root() if hasattr(self, "find_root") else None
host = root if isinstance(root, Node) else self
host.add_child(dialog)
return dialog
def _ensure_duplicate_dialog(self):
state = getattr(self, "state", None)
if state is None:
return None
dialog = getattr(state, "_duplicate_node_dialog", None)
if dialog is None:
from simvx.editor.duplicate_node_dialog import DuplicateNodeDialog
dialog = DuplicateNodeDialog(name="DuplicateNodeDialog")
state._duplicate_node_dialog = dialog
root = self.find_root() if hasattr(self, "find_root") else None
host = root if isinstance(root, Node) else self
host.add_child(dialog)
return dialog
def _ctx_rename(self):
"""Show the inline rename overlay for the selected node."""
if self._is_play_mode_active():
return
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 self._is_play_mode_active():
return
if not self.state.clipboard.has_node():
return
parent = self._selected_node()
if parent is None:
parent = self.scope_root
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 = AddNodeCommand(parent, new_node)
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."""
if self._is_play_mode_active():
return
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 back button (visible only when inside a composed node)
has_breadcrumb = bool(self._scope_stack)
header_left = 6.0
if has_breadcrumb:
self._back_btn.position = Vec2(6, 3)
header_left = 34.0 # After back button
# Position "+" button at the right end of the header
btn_x = gw - 30
self._add_btn.position = Vec2(btn_x, 3)
# Mode-cycle and clear buttons sit between filter edit and "+" button.
# Sizes follow Button.get_minimum_size() (content-driven, per the
# sizing-model rule -- no hard-pixel widths).
mode_size = self._filter_mode_btn.get_minimum_size()
clear_size = self._filter_clear_btn.get_minimum_size()
self._filter_mode_btn.size = Vec2(mode_size.x, 22)
self._filter_clear_btn.size = Vec2(clear_size.x, 22)
gap = 4.0
# Right edge of clear button is just left of "+" button.
clear_x = btn_x - clear_size.x - gap
mode_x = clear_x - mode_size.x - gap
# Filter edit takes a moderate width so the panel header still
# exposes a clickable bare strip on the left for context-menu access.
edit_right = mode_x - gap
filter_w = min(70, edit_right - header_left)
filter_w = max(60, filter_w)
edit_x = edit_right - filter_w
self._filter_edit.position = Vec2(edit_x, 3)
self._filter_edit.size = Vec2(filter_w, 22)
self._filter_mode_btn.position = Vec2(mode_x, 3)
self._filter_clear_btn.position = Vec2(clear_x, 3)
# Clear button is only meaningful when there is text to clear.
self._filter_clear_btn.visible = bool(self._filter_text)
# Compute tree top offset (header + optional breadcrumb bar)
tree_top = self.HEADER_HEIGHT
if has_breadcrumb:
tree_top += self.BREADCRUMB_HEIGHT
# Position tree below header (and breadcrumb if present)
self._tree_view.position = Vec2(0, tree_top)
self._tree_view.size = Vec2(gw, gh - tree_top)
# ---------------------------------------------------------------- draw
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Panel background
renderer.draw_rect((x, y), (w, h), colour=self._bg_colour, filled=True)
# Header bar
renderer.draw_rect((x, y), (w, self.HEADER_HEIGHT), colour=self._header_bg, filled=True)
# Bottom border of header
renderer.draw_line(
(x, y + self.HEADER_HEIGHT), (x + w, y + self.HEADER_HEIGHT), colour=(0.32, 0.32, 0.36, 1.0)
)
# Invalid-regex indicator: red outline around the filter input. Drawn
# by the panel rather than mutating the shared TextEdit StyleBox.
if self._filter_regex_invalid:
fx, fy, fw, fh = self._filter_edit.get_global_rect()
renderer.draw_rect((fx, fy), (fw, fh), colour=(0.85, 0.25, 0.25, 1.0), filled=False)
# Breadcrumb bar (shown when navigated into a composed node)
if self._scope_stack:
bc_y = y + self.HEADER_HEIGHT
renderer.draw_rect((x, bc_y), (w, self.BREADCRUMB_HEIGHT), colour=self._breadcrumb_bg, filled=True)
crumbs = self._breadcrumb_path()
bx = x + 6
bc_scale = 0.85
for i, (label, _node) in enumerate(crumbs):
if i > 0:
renderer.draw_text(" \u203a ", (bx, bc_y + 4), colour=self._breadcrumb_text, scale=bc_scale)
bx += 18
colour = self._breadcrumb_hover if _node is not None else self._breadcrumb_text
renderer.draw_text(label, (bx, bc_y + 4), colour=colour, scale=bc_scale)
bx += len(label) * 7
renderer.draw_line(
(x, bc_y + self.BREADCRUMB_HEIGHT), (x + w, bc_y + self.BREADCRUMB_HEIGHT),
colour=(0.25, 0.25, 0.30, 1.0),
)