Source code for simvx.editor.panels.scene_tree.panel

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