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