Source code for simvx.editor.duplicate_node_dialog

"""Duplicate Node dialog — three semantically distinct duplication variants.

Opened from the Scene Tree right-click menu (or programmatically) for the
currently-selected node. The user picks one of:

1. **New Instance** — parent gains another ``add_child(<SameClass>(...))``
   call with the same property kwargs as the original. No source file is
   created.

2. **Subclass** — prompts for a class name (default ``<Original>2``), creates
   ``<class_files_dir>/<snake_case_name>.py`` with
   ``class <Name>(<Original>): pass``, then adds an instance of the new
   class under the parent. For built-in originals the new file's base is
   the built-in class itself — the same mechanism with no special-casing.

3. **Detached Copy** — prompts for a class name, creates a new file with
   ``class <Name>(<OriginalBaseClass>): <body>`` where ``<body>`` is copied
   *verbatim* from the original class definition (parsed via
   :func:`simvx.core.scene_io.parse_source`) but rebased on the original's
   own base class — *not* on the original class itself. For built-in
   originals (no class file to copy from) this degenerates to a
   ``pass``-bodied file, identical to Subclass.

The dialog blocks submit when the chosen class name collides with any
class already known to :class:`~simvx.editor.project_classes.ProjectClassIndex`.

The dialog is decoupled from the panel that hosts it — it does not depend
on :class:`~simvx.editor.panels.scene_tree.panel.SceneTreePanel` and can be
opened from a context menu, command palette, or test harness directly. The
caller wires :attr:`submitted` to receive the new live :class:`~simvx.core.Node`.
"""

from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any

from simvx.core import (
    Button,
    HBoxContainer,
    Label,
    Node,
    Panel,
    RadioButton,
    Signal,
    TextEdit,
    VBoxContainer,
    Vec2,
    Vec3,
)
from simvx.core.math.types import Quat
from simvx.core.scene_io import parse_source

from .project_classes import ProjectClass, ProjectClassIndex

if TYPE_CHECKING:
    pass

log = logging.getLogger(__name__)

__all__ = ["DUPLICATE_VARIANTS", "DuplicateNodeDialog"]

# Variant identifiers shared with tests + callers.
DUPLICATE_VARIANTS = ("instance", "subclass", "detached")

# camelCase / PascalCase → snake_case (used for generated file names).
_CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")


def _snake_case(name: str) -> str:
    """``MyClassName`` → ``my_class_name``. Empty input → empty output."""
    if not name:
        return ""
    return _CAMEL_RE.sub("_", name).lower()


# ---------------------------------------------------------------------------
# Live-tree property iteration (mirror of emitter.iter_runtime_kwargs)
# ---------------------------------------------------------------------------


def _is_default_value(val: Any, default: Any) -> bool:
    """Compare two values, tolerating numpy arrays."""
    try:
        eq = val == default
        if hasattr(eq, "all"):
            return bool(eq.all())
        return bool(eq)
    except Exception:
        return False


def _live_kwargs(node: Node) -> dict[str, Any]:
    """Return the constructor kwargs that reproduce ``node``'s state.

    Mirrors the semantics of
    :func:`simvx.core.scene_io.iter_runtime_kwargs` but yields live
    Python values (not formatted source strings) so they can be passed
    directly to ``type(node)(**kwargs)``. Includes:

    * ``name`` when ``node.name`` differs from the type name.
    * Spatial kwargs (``position`` / ``rotation`` / ``scale``) when they
      deviate from origin / identity / one (Node2D + Node3D only).
    * Every declared :class:`Property` whose current value differs from
      its declared default.
    """
    from simvx.core.nodes_2d.node2d import Node2D
    from simvx.core.nodes_3d.node3d import Node3D

    out: dict[str, Any] = {}
    real_type = type(node)
    if node.name and node.name != real_type.__name__:
        out["name"] = node.name

    if isinstance(node, Node3D):
        pos = node.position
        if not (abs(float(pos[0])) < 1e-9 and abs(float(pos[1])) < 1e-9 and abs(float(pos[2])) < 1e-9):
            out["position"] = Vec3(pos)
        rot = node.rotation
        if not (abs(float(rot.w) - 1.0) < 1e-9 and abs(float(rot.x)) < 1e-9
                and abs(float(rot.y)) < 1e-9 and abs(float(rot.z)) < 1e-9):
            out["rotation"] = Quat(float(rot.w), float(rot.x), float(rot.y), float(rot.z))
        scl = node.scale
        if not (abs(float(scl[0]) - 1.0) < 1e-9 and abs(float(scl[1]) - 1.0) < 1e-9
                and abs(float(scl[2]) - 1.0) < 1e-9):
            out["scale"] = Vec3(scl)
    elif isinstance(node, Node2D):
        pos = node.position
        if not (abs(float(pos[0])) < 1e-9 and abs(float(pos[1])) < 1e-9):
            out["position"] = Vec2(pos)
        if abs(float(node.rotation)) > 1e-9:
            out["rotation"] = float(node.rotation)
        scl = node.scale
        if not (abs(float(scl[0]) - 1.0) < 1e-9 and abs(float(scl[1]) - 1.0) < 1e-9):
            out["scale"] = Vec2(scl)

    for prop_name, prop in node.get_properties().items():
        val = getattr(node, prop_name)
        if _is_default_value(val, prop.default):
            continue
        out[prop_name] = val
    return out


# ---------------------------------------------------------------------------
# Class-body extraction (Detached Copy)
# ---------------------------------------------------------------------------


def _extract_class_body(file_path: Path, class_name: str) -> str | None:
    """Return the verbatim suite text of ``class_name`` in ``file_path``.

    Reads the file, parses it via :func:`parse_source`, and returns the
    ``suite``-node code (everything after ``:`` on the class header,
    including the leading newline). Returns ``None`` when the file cannot
    be read, the source has parse errors, or the class is not found at
    module top level.
    """
    try:
        source = file_path.read_text(encoding="utf-8")
    except OSError:
        log.warning("duplicate_node_dialog: cannot read %s", file_path)
        return None
    try:
        tree = parse_source(source)
    except Exception:
        log.warning("duplicate_node_dialog: parse failed for %s", file_path, exc_info=True)
        return None
    if tree.errors:
        log.warning("duplicate_node_dialog: %s has syntax errors; skipping body copy", file_path)
        return None
    classdef = tree.find_class(class_name)
    if classdef is None:
        return None
    # parso classdef children: [class, NAME, (?, bases?, )?, :, suite]
    suite = classdef.children[-1]
    return suite.get_code()


# ---------------------------------------------------------------------------
# Dialog
# ---------------------------------------------------------------------------


[docs] class DuplicateNodeDialog(Panel): """Modal Duplicate Node dialog hosting the three duplication variants. Lifecycle: dialog = DuplicateNodeDialog() dialog.submitted.connect(handler) dialog.show_for(node, project_index=index, project_path=path) The handler receives the newly-created ``Node`` instance (already parented to ``node.parent``). When the variant creates a file, the file is written to disk before ``submitted`` fires. """ DIALOG_W = 480.0 DIALOG_H = 320.0 def __init__(self, **kwargs): super().__init__(**kwargs) self.visible = False self.z_index = 1700 # Full-screen transparent backdrop so clicks outside the inner panel # don't leak to widgets below. self.bg_colour = (0.0, 0.0, 0.0, 0.55) self.border_width = 0 # External state — populated by show_for(). self._original: Node | None = None self._project_index: ProjectClassIndex | None = None self._project_path: Path | None = None self._class_files_dir: str = "src" self._project_classes: list[ProjectClass] = [] self._original_project_class: ProjectClass | None = None # Result signal — fires with the newly-created Node after Submit. self.submitted = Signal() self.cancelled = Signal() # Build UI shell (widgets get re-positioned in show_for / draw_popup). self._inner = Panel(name="DuplicateInner") self._inner.bg_colour = (0.16, 0.16, 0.18, 1.0) self._inner.border_colour = (0.36, 0.36, 0.40, 1.0) self._inner.size = Vec2(self.DIALOG_W, self.DIALOG_H) self.add_child(self._inner) body = VBoxContainer(name="DuplicateBody") body.position = Vec2(20, 16) body.size = Vec2(self.DIALOG_W - 40, self.DIALOG_H - 32) body.separation = 10.0 self._inner.add_child(body) self._title_label = Label("Duplicate Node", name="DuplicateTitle") self._title_label.font_size = 16.0 body.add_child(self._title_label) self._target_label = Label("", name="DuplicateTarget") self._target_label.font_size = 12.0 body.add_child(self._target_label) # Radio group — three variants, default = New Instance. self._radio_instance = RadioButton( "New Instance — drop another instance of the same class", group="duplicate_variant", selected=True, name="RadioInstance", ) self._radio_subclass = RadioButton( "Subclass — create a class that inherits from the original", group="duplicate_variant", name="RadioSubclass", ) self._radio_detached = RadioButton( "Detached Copy — copy the class body under a fresh name", group="duplicate_variant", name="RadioDetached", ) body.add_child(self._radio_instance) body.add_child(self._radio_subclass) body.add_child(self._radio_detached) self._radio_instance.selection_changed.connect(self._on_variant_changed) self._radio_subclass.selection_changed.connect(self._on_variant_changed) self._radio_detached.selection_changed.connect(self._on_variant_changed) # Class-name field (Subclass / Detached only). name_row = HBoxContainer(name="NameRow") name_row.separation = 8.0 self._name_label = Label("New class name:", name="NameLabel") self._name_label.font_size = 12.0 self._name_edit = TextEdit(placeholder="ClassName", name="NameEdit") self._name_edit.size = Vec2(220, 24) self._name_edit.font_size = 12.0 self._name_edit.text_changed.connect(self._on_name_changed) name_row.add_child(self._name_label) name_row.add_child(self._name_edit) body.add_child(name_row) # File path preview + status / error line. self._path_label = Label("", name="PathLabel") self._path_label.font_size = 11.0 self._path_label.text_colour = (0.65, 0.68, 0.72, 1.0) body.add_child(self._path_label) self._error_label = Label("", name="ErrorLabel") self._error_label.font_size = 11.0 self._error_label.text_colour = (0.95, 0.50, 0.45, 1.0) body.add_child(self._error_label) # Action row. actions = HBoxContainer(name="DuplicateActions") actions.separation = 8.0 self._cancel_btn = Button("Cancel", name="DuplicateCancel") self._cancel_btn.size = Vec2(80, 26) self._cancel_btn.pressed.connect(self._on_cancel) self._submit_btn = Button("Duplicate", name="DuplicateSubmit") self._submit_btn.size = Vec2(110, 26) self._submit_btn.pressed.connect(self._on_submit) actions.add_child(self._cancel_btn) actions.add_child(self._submit_btn) body.add_child(actions) # ------------------------------------------------------------------ public
[docs] def show_for( self, node: Node, *, project_index: ProjectClassIndex | None = None, project_path: Path | None = None, class_files_dir: str = "src", ) -> None: """Open the dialog for ``node``. ``project_index`` drives collision detection and is also used to determine whether the original is user-defined (so Detached Copy knows where to read the class body from). ``project_path`` and ``class_files_dir`` resolve the destination directory for newly generated files; the dialog refuses to submit a Subclass / Detached Copy when ``project_path`` is unset. """ if node.parent is None: log.warning("duplicate_node_dialog: cannot duplicate root node %s", node.name) return self._original = node self._project_index = project_index self._project_path = Path(project_path) if project_path is not None else None self._class_files_dir = class_files_dir or "src" if project_index is not None: self._project_classes = project_index.refresh() else: self._project_classes = [] original_name = type(node).__name__ self._original_project_class = next( (pc for pc in self._project_classes if pc.name == original_name), None, ) self._target_label.text = f"Duplicating {node.name} ({original_name})" self._radio_instance.selected = True self._radio_subclass.selected = False self._radio_detached.selected = False self._name_edit.text = f"{original_name}2" self._error_label.text = "" self.visible = True self._sync_ui() if self._tree: self._tree.push_popup(self)
[docs] def dismiss(self) -> None: """Hide the dialog. Idempotent.""" if not self.visible: return self.visible = False if self._tree: self._tree.pop_popup(self)
[docs] @property def variant(self) -> str: if self._radio_subclass.selected: return "subclass" if self._radio_detached.selected: return "detached" return "instance"
# -------------------------------------------------------- popup interface
[docs] def is_popup_point_inside(self, point) -> bool: return self.visible
[docs] def popup_input(self, event): """Forward popup events; click outside the inner panel cancels.""" if not self.visible: return if hasattr(event, "key") and event.key and event.pressed: if event.key == "escape": self._on_cancel() return if event.key in ("enter", "return"): self._on_submit() return
[docs] def dismiss_popup(self) -> None: self._on_cancel()
# ------------------------------------------------------- submit / cancel def _on_cancel(self): self.dismiss() self.cancelled.emit() def _on_submit(self): if self._original is None: return if self._error_label.text: return # collision blocks submit variant = self.variant try: if variant == "instance": new_node = self._do_new_instance() elif variant == "subclass": new_node = self._do_subclass() elif variant == "detached": new_node = self._do_detached_copy() else: return except Exception: log.exception("duplicate_node_dialog: submit failed for variant %r", variant) return if new_node is None: return self.dismiss() self.submitted.emit(new_node) # -------------------------------------------------------------- variants def _do_new_instance(self) -> Node | None: """Create another instance of the same class under the same parent. Kwargs are copied verbatim from the original via :func:`_live_kwargs` (the live counterpart of :func:`simvx.core.scene_io.iter_runtime_kwargs`). The new instance is appended to the parent's children in source order. """ original = self._original if original is None or original.parent is None: return None kwargs = _live_kwargs(original) # Drop the original's name kwarg; the instance gets a fresh name. kwargs.pop("name", None) new_node = type(original)(**kwargs) new_node.name = self._fresh_sibling_name(original) original.parent.add_child(new_node) return new_node def _do_subclass(self) -> Node | None: """Generate ``class <Name>(<Original>): pass`` and instantiate it. For built-in originals (no project class file) the base is the built-in class itself — the file's ``from`` import targets ``simvx.core``. For user-defined originals, the base is imported from the original's project module path. """ original = self._original if original is None or original.parent is None: return None new_class_name = self._name_edit.text.strip() if not self._validate_class_name(new_class_name): return None original_name = type(original).__name__ new_cls = self._write_subclass_file(new_class_name, base_name=original_name) if new_cls is None: return None new_node = new_cls(name=new_class_name) original.parent.add_child(new_node) return new_node def _do_detached_copy(self) -> Node | None: """Generate ``class <Name>(<OriginalBaseClass>): <body>`` and instantiate. ``<body>`` is the verbatim suite text of the original class (parsed via :func:`parse_source` and lifted via parso's ``get_code()``). For built-in originals there is no class file to copy from, so the file falls back to a ``pass`` body — same shape as Subclass — but still rebased on the original's class itself (which *is* the base class for built-ins). """ original = self._original if original is None or original.parent is None: return None new_class_name = self._name_edit.text.strip() if not self._validate_class_name(new_class_name): return None original_name = type(original).__name__ body_text: str | None = None base_name = original_name if self._original_project_class is not None: # User-defined original — copy its body and rebase on its parent. body_text = _extract_class_body( self._original_project_class.file_path, original_name ) bases = self._original_project_class.bases base_name = bases[0] if bases else "Node" # Built-in originals (or extraction failure) → no body to copy; the # generated file degenerates to ``pass`` over the same base class # the Subclass variant would use. new_cls = self._write_subclass_file( new_class_name, base_name=base_name, body_text=body_text, ) if new_cls is None: return None new_node = new_cls(name=new_class_name) original.parent.add_child(new_node) return new_node # -------------------------------------------------------- file emission def _write_subclass_file( self, new_class_name: str, *, base_name: str, body_text: str | None = None, ) -> type[Node] | None: """Write ``<class_files_dir>/<snake_case>.py`` and import the new class. ``body_text`` is the parso-extracted suite text of an existing class (used by Detached Copy). When ``None``, the body is a single ``pass`` line, matching the Subclass variant's shape. """ if self._project_path is None: self._error_label.text = "No project: open a project before creating files." return None target_dir = self._project_path / self._class_files_dir target_dir.mkdir(parents=True, exist_ok=True) file_path = target_dir / f"{_snake_case(new_class_name)}.py" if file_path.exists(): self._error_label.text = f"File already exists: {file_path.name}" return None # Resolve the import path for the base class. User-defined originals # come with a recorded module path (e.g. ``player.attack``); built-in # bases use ``simvx.core``. if ( self._original_project_class is not None and self._original_project_class.name == base_name ): base_module = self._original_project_class.module_path else: base_module = "simvx.core" if body_text is None: source = self._render_pass_file(new_class_name, base_name, base_module) else: source = self._render_body_file( new_class_name, base_name, base_module, body_text ) file_path.write_text(source, encoding="utf-8") # Refresh the index so the new class is discoverable next time the # picker opens, and so future collision checks see it. if self._project_index is not None: self._project_classes = self._project_index.refresh() return self._import_new_class(file_path, new_class_name) def _render_pass_file(self, class_name: str, base_name: str, base_module: str) -> str: return ( f"from {base_module} import {base_name}\n" f"\n" f"\n" f"class {class_name}({base_name}):\n" f" pass\n" ) def _render_body_file( self, class_name: str, base_name: str, base_module: str, body_text: str ) -> str: # ``body_text`` is the parso ``suite`` code, which already starts with # a newline + indent. We splice it directly after ``class X(Base):`` # to preserve formatting verbatim. if not body_text.startswith("\n"): body_text = "\n" + body_text return ( f"from {base_module} import {base_name}\n" f"\n" f"\n" f"class {class_name}({base_name}):" f"{body_text}" ) def _import_new_class(self, file_path: Path, class_name: str) -> type[Node] | None: """Import ``file_path`` and return the named class. We use :mod:`importlib.util` directly so the lookup works whether or not ``<project>/src`` is on ``sys.path``. Any failure here is a bug in the file we just wrote, so we log loudly and abort. """ import importlib.util spec = importlib.util.spec_from_file_location(_snake_case(class_name), file_path) if spec is None or spec.loader is None: log.error("duplicate_node_dialog: cannot import %s", file_path) return None module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) except Exception: log.exception("duplicate_node_dialog: failed to exec %s", file_path) return None cls = getattr(module, class_name, None) if not isinstance(cls, type) or not issubclass(cls, Node): log.error( "duplicate_node_dialog: %s is not a Node subclass in %s", class_name, file_path, ) return None return cls # -------------------------------------------------------------- helpers def _validate_class_name(self, name: str) -> bool: """Update the error label and return True iff ``name`` is acceptable.""" if not name: self._error_label.text = "Class name is required." return False if not name.isidentifier(): self._error_label.text = "Class name must be a valid Python identifier." return False if self._collides(name): self._error_label.text = f"A class named {name!r} already exists." return False self._error_label.text = "" return True def _collides(self, name: str) -> bool: """True iff ``name`` clashes with any known project or built-in class.""" for pc in self._project_classes: if pc.name == name: return True # Also reject built-in Node subclass names — creating a file # ``button.py`` that shadows the engine's ``Button`` is the same # category of mistake. from .project_classes import _builtin_node_subclass_names if name in _builtin_node_subclass_names(): return True return False def _fresh_sibling_name(self, original: Node) -> str: """``Foo`` → ``Foo2``; ``Foo2`` → ``Foo3``; resolves collisions in the parent's existing children.""" existing = {c.name for c in original.parent.children} if original.parent else set() base = original.name m = re.match(r"^(.*?)(\d+)$", base) if m: stem = m.group(1) n = int(m.group(2)) else: stem = base n = 1 candidate = f"{stem}{n + 1}" while candidate in existing: n += 1 candidate = f"{stem}{n + 1}" return candidate def _on_name_changed(self, _text: str): self._sync_ui() def _on_variant_changed(self, _selected: bool): self._sync_ui() def _sync_ui(self): """Refresh visibility/labels and re-validate the name field.""" wants_name = self.variant in ("subclass", "detached") self._name_label.visible = wants_name self._name_edit.visible = wants_name self._path_label.visible = wants_name if wants_name: name = self._name_edit.text.strip() if name and self._project_path is not None: file_path = ( self._project_path / self._class_files_dir / f"{_snake_case(name)}.py" ) self._path_label.text = f"Will create: {file_path}" elif self._project_path is None: self._path_label.text = "(no project — open a project to create class files)" else: self._path_label.text = "" # Validate eagerly so the Submit button reflects collision state. self._validate_class_name(name) else: self._path_label.text = "" self._error_label.text = "" # ------------------------------------------------------------ rendering
[docs] def draw(self, renderer): """Backdrop only — the inner :class:`Panel` draws itself.""" if not self.visible: return ss = self._get_parent_size() sw, sh = ss.x, ss.y renderer.draw_rect((0, 0), (sw, sh), colour=self.bg_colour, filled=True)
[docs] def draw_popup(self, renderer): """Centre the inner panel before letting the tree draw it.""" if not self.visible: return ss = self._get_parent_size() sw, sh = ss.x, ss.y # Drop the modal backdrop here so it covers the entire viewport. renderer.draw_rect((0, 0), (sw, sh), colour=self.bg_colour, filled=True) # Centre the inner panel; child draw is handled by the popup walker. self._inner.position = Vec2((sw - self.DIALOG_W) / 2, (sh - self.DIALOG_H) / 2) self._inner._draw_recursive(renderer)