"""Make Custom Class dialog -- promote a built-in node to a user-defined subclass.
Opens from the Inspector toolbar when the selected node's class lives under
``simvx.core.*``. The user picks:
* a class name (defaults to the node's ``name``);
* a destination -- either a brand-new file under ``[editor].class_files_dir``
(``snake_case_name.py``) or an inline insertion at module scope of the
parent scene's ``.py`` file.
On submit the dialog:
1. Writes / mutates the destination via :class:`simvx.core.scene_io.SceneFile`
primitives so formatting, comments, and unrelated code are preserved.
2. Imports the new module, swaps the runtime node's class to the freshly
defined subclass, and triggers ``state.save_scene()`` so the parent scene's
``add_child(...)`` line picks up the new constructor name.
3. Opens the new file (or jumps to the inline class block) in the editor's
code workspace.
Name collisions are detected up-front via :class:`ProjectClassIndex` so the
submit button is blocked when the user picks a name already defined elsewhere
in the project.
"""
from __future__ import annotations
import importlib
import importlib.util
import logging
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import (
Button,
HBoxContainer,
Label,
Node,
Panel,
RadioButton,
Signal,
TextEdit,
VBoxContainer,
Vec2,
)
from simvx.core.scene_io import SceneFile
from .project_classes import ProjectClassIndex
if TYPE_CHECKING:
from .state import State
log = logging.getLogger(__name__)
__all__ = ["MakeCustomClassDialog", "snake_case"]
_DIALOG_W = 460.0
_DIALOG_H = 280.0
_PAD = 16.0
_LABEL_W = 110.0
_FIELD_W = _DIALOG_W - 2 * _PAD - _LABEL_W
_ROW_H = 28.0
_OVERLAY = (0.0, 0.0, 0.0, 0.55)
_BG = (0.16, 0.16, 0.18, 1.0)
_BORDER = (0.35, 0.35, 0.40, 1.0)
_TITLE = (0.95, 0.95, 0.97, 1.0)
_LABEL = (0.78, 0.78, 0.82, 1.0)
_HINT = (0.55, 0.55, 0.58, 1.0)
_ERROR = (0.95, 0.45, 0.45, 1.0)
_SNAKE_BOUNDARY = re.compile(r"(?<=[a-z0-9])([A-Z])")
_NON_IDENT = re.compile(r"[^A-Za-z0-9_]+")
[docs]
def snake_case(name: str) -> str:
"""Convert ``CamelCase`` / ``mixed Name`` to ``camel_case`` for filenames."""
name = _NON_IDENT.sub("_", name.strip())
name = _SNAKE_BOUNDARY.sub(r"_\1", name).lower()
name = re.sub(r"_+", "_", name).strip("_")
return name or "node"
def _is_builtin_class(cls: type) -> bool:
"""True iff ``cls`` is shipped under the ``simvx.core.*`` namespace."""
module = getattr(cls, "__module__", "") or ""
return module == "simvx.core" or module.startswith("simvx.core.")
[docs]
class MakeCustomClassDialog(Panel):
"""Modal dialog that turns a built-in node instance into a user subclass.
Construct once per editor; call :meth:`show_for` with the target node
each time the Inspector promote button fires. The dialog reads
``state.project_path`` and the ``[editor].class_files_dir`` setting to
place new files; if the project has no ``simvx.toml`` the default is
``src/`` (matching :class:`ProjectClassIndex`).
"""
DIALOG_W = _DIALOG_W
DIALOG_H = _DIALOG_H
created = Signal() # (node, class_obj, source_path)
cancelled = Signal()
def __init__(self, state: State | None = None, **kwargs):
super().__init__(**kwargs)
self.state = state
self.bg_colour = _OVERLAY
self.border_width = 0
self.visible = False
self.z_index = 1700
self._target_node: Node | None = None
self._project_index: ProjectClassIndex | None = None
self._inner: Panel | None = None
self._title: Label | None = None
self._subtitle: Label | None = None
self._name_edit: TextEdit | None = None
self._new_file_radio: RadioButton | None = None
self._inline_radio: RadioButton | None = None
self._destination_label: Label | None = None
self._error_label: Label | None = None
self._submit_btn: Button | None = None
self._build()
# ------------------------------------------------------------------ build
def _build(self) -> None:
inner = Panel(name="MakeCustomClassDialogInner")
inner.bg_colour = _BG
inner.border_colour = _BORDER
inner.border_width = 1.0
inner.size = Vec2(_DIALOG_W, _DIALOG_H)
self._inner = inner
vbox = VBoxContainer(name="MakeCustomClassVBox")
vbox.separation = 8
vbox.position = Vec2(_PAD, _PAD)
vbox.size = Vec2(_DIALOG_W - 2 * _PAD, _DIALOG_H - 2 * _PAD)
inner.add_child(vbox)
self._title = Label("Make Custom Class", name="Title")
self._title.font_size = 16.0
self._title.text_colour = _TITLE
self._title.size = Vec2(_DIALOG_W - 2 * _PAD, 22)
vbox.add_child(self._title)
self._subtitle = Label("", name="Subtitle")
self._subtitle.font_size = 11.0
self._subtitle.text_colour = _HINT
self._subtitle.size = Vec2(_DIALOG_W - 2 * _PAD, 16)
vbox.add_child(self._subtitle)
# Class name row.
name_row = HBoxContainer(name="NameRow")
name_row.separation = 8
name_row.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H)
name_label = Label("Class name", name="NameLabel")
name_label.font_size = 13.0
name_label.text_colour = _LABEL
name_label.size = Vec2(_LABEL_W, _ROW_H)
name_row.add_child(name_label)
self._name_edit = TextEdit(name="ClassNameEdit")
self._name_edit.size = Vec2(_FIELD_W, _ROW_H)
self._name_edit.text_changed.connect(self._on_name_changed)
name_row.add_child(self._name_edit)
vbox.add_child(name_row)
# Destination radios live in a sub-VBox so they stack neatly.
location_row = VBoxContainer(name="LocationGroup")
location_row.separation = 4
location_row.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H * 2 + 4)
self._new_file_radio = RadioButton(
"New file in project src",
group="MakeCustomClassLocation",
selected=True,
name="NewFileRadio",
)
self._new_file_radio.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H)
self._new_file_radio.selection_changed.connect(self._on_location_changed)
location_row.add_child(self._new_file_radio)
self._inline_radio = RadioButton(
"Inline in parent scene file",
group="MakeCustomClassLocation",
selected=False,
name="InlineRadio",
)
self._inline_radio.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H)
self._inline_radio.selection_changed.connect(self._on_location_changed)
location_row.add_child(self._inline_radio)
vbox.add_child(location_row)
self._destination_label = Label("", name="DestinationLabel")
self._destination_label.font_size = 11.0
self._destination_label.text_colour = _HINT
self._destination_label.size = Vec2(_DIALOG_W - 2 * _PAD, 16)
vbox.add_child(self._destination_label)
self._error_label = Label("", name="ErrorLabel")
self._error_label.font_size = 11.0
self._error_label.text_colour = _ERROR
self._error_label.size = Vec2(_DIALOG_W - 2 * _PAD, 16)
vbox.add_child(self._error_label)
# Buttons row.
btn_row = HBoxContainer(name="ButtonRow")
btn_row.separation = 8
btn_row.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H + 4)
cancel_btn = Button("Cancel", name="CancelBtn")
cancel_btn.size = Vec2(110, _ROW_H)
cancel_btn.pressed.connect(self._on_cancel)
btn_row.add_child(cancel_btn)
spacer = Label("", name="Spacer")
spacer.size = Vec2(_DIALOG_W - 2 * _PAD - 110 - 130 - 16, _ROW_H)
btn_row.add_child(spacer)
self._submit_btn = Button("Create", name="SubmitBtn")
self._submit_btn.size = Vec2(130, _ROW_H)
self._submit_btn.pressed.connect(self._on_submit)
btn_row.add_child(self._submit_btn)
vbox.add_child(btn_row)
self.add_child(inner)
# ------------------------------------------------------------ public API
[docs]
def set_project_index(self, index: ProjectClassIndex | None) -> None:
"""Wire (or clear) the project-class index used for collision detection."""
self._project_index = index
[docs]
def show_for(self, node: Node, parent_size: Vec2 | None = None) -> None:
"""Open the dialog targeting ``node`` and centre it within ``parent_size``."""
self._target_node = node
if self._name_edit is not None:
self._name_edit.text = self._default_class_name(node)
self._name_edit.cursor_pos = len(self._name_edit.text)
if self._inline_radio is not None:
inline_available = self._parent_scene_path() is not None
self._inline_radio.disabled = not inline_available
if not inline_available and self._inline_radio.selected:
self._inline_radio.selected = False
if self._new_file_radio is not None:
self._new_file_radio.selected = True
base_name = type(node).__name__ if node is not None else ""
if self._subtitle is not None:
self._subtitle.text = f"{node.name} (extends {base_name})" if node else ""
if self._error_label is not None:
self._error_label.text = ""
self._refresh_destination_hint()
# Refresh project index so collision checks see the latest source.
if self._project_index is not None:
self._project_index.refresh()
# Centre and show.
if parent_size is not None:
self.size = parent_size
if self.size.x > 0 and self.size.y > 0 and self._inner is not None:
self._inner.position = Vec2(
(self.size.x - _DIALOG_W) / 2, (self.size.y - _DIALOG_H) / 2
)
self.visible = True
self._validate()
if self._tree and self._name_edit is not None:
self._tree._set_focused_control(self._name_edit)
[docs]
def hide_dialog(self) -> None:
"""Hide the dialog without emitting a result."""
self.visible = False
# ------------------------------------------------------------ internals
def _default_class_name(self, node: Node | None) -> str:
"""Best-guess CamelCase class name from the node's display name."""
if node is None:
return ""
raw = _NON_IDENT.sub(" ", node.name).strip()
if not raw:
raw = type(node).__name__
parts = [p for p in raw.split() if p]
camel = "".join(p[:1].upper() + p[1:] for p in parts)
return camel or type(node).__name__
def _parent_scene_path(self) -> Path | None:
if self.state is None:
return None
path = getattr(self.state, "current_scene_path", None)
if isinstance(path, str):
path = Path(path)
return path if isinstance(path, Path) and path.exists() else None
def _project_path(self) -> Path | None:
if self.state is None:
return None
return getattr(self.state, "project_path", None)
def _class_files_dir(self) -> str:
"""Resolved ``[editor].class_files_dir`` (defaults to ``src``)."""
if self._project_index is not None and getattr(self._project_index, "_src_subdir", None):
return str(self._project_index._src_subdir)
return "src"
def _new_file_target(self, name: str) -> Path | None:
project = self._project_path()
if project is None or not name:
return None
return project / self._class_files_dir() / f"{snake_case(name)}.py"
def _refresh_destination_hint(self) -> None:
if self._destination_label is None:
return
name = (self._name_edit.text if self._name_edit else "").strip()
if self._new_file_radio is not None and self._new_file_radio.selected:
target = self._new_file_target(name) if name else None
self._destination_label.text = f"-> {target}" if target else "-> (no project root)"
else:
scene_path = self._parent_scene_path()
self._destination_label.text = (
f"-> {scene_path}" if scene_path else "-> (parent scene unsaved)"
)
def _on_name_changed(self, _text: str) -> None:
self._refresh_destination_hint()
self._validate()
def _on_location_changed(self, _selected: bool) -> None:
self._refresh_destination_hint()
self._validate()
def _validate(self) -> bool:
"""Update submit-button enabled state and the error label.
Returns ``True`` when the form is submittable.
"""
name = (self._name_edit.text if self._name_edit else "").strip()
error = self._validation_error(name)
if self._error_label is not None:
self._error_label.text = error or ""
if self._submit_btn is not None:
self._submit_btn.disabled = bool(error)
return not error
def _validation_error(self, name: str) -> str | None:
if not name:
return "Class name required"
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
return "Invalid Python identifier"
if self._target_node is None:
return "No node selected"
if self._inline_radio is not None and self._inline_radio.selected:
if self._parent_scene_path() is None:
return "Save the parent scene before inlining"
else:
if self._project_path() is None:
return "Project root required for new file"
target = self._new_file_target(name)
if target is not None and target.exists():
return f"File already exists: {target.name}"
# Project-wide collision check.
if self._project_index is not None:
for pc in self._project_index.all():
if pc.name == name:
return f"Class {name!r} already defined in {pc.module_path or pc.file_path}"
return None
# ----------------------------------------------------------- submission
def _on_cancel(self) -> None:
self.hide_dialog()
self.cancelled.emit()
def _on_submit(self) -> None:
if not self._validate():
return
node = self._target_node
if node is None or self.state is None:
return
name = self._name_edit.text.strip() if self._name_edit else ""
base_name = type(node).__name__
try:
if self._inline_radio is not None and self._inline_radio.selected:
source_path = self._create_inline(node, name, base_name)
else:
source_path = self._create_new_file(node, name, base_name)
except Exception as exc: # noqa: BLE001 - user-facing error path
log.exception("MakeCustomClass: failed to create class")
if self._error_label is not None:
self._error_label.text = f"Error: {exc}"
return
new_cls = self._import_new_class(source_path, name)
if new_cls is None:
if self._error_label is not None:
self._error_label.text = "Class created but import failed -- see logs"
return
node.__class__ = new_cls
self.state.modified = True
# Persist parent scene so add_child(... new ctor) is written out.
try:
self.state.save_scene()
except Exception: # noqa: BLE001 - save_scene already logs; keep dialog flow
log.exception("MakeCustomClass: save_scene failed after class creation")
self._open_in_editor(source_path, name)
# Refresh project index so the new class is visible to other tools.
if self._project_index is not None:
self._project_index.refresh()
self.created.emit(node, new_cls, source_path)
self.hide_dialog()
def _create_new_file(self, node: Node, name: str, base_name: str) -> Path:
target = self._new_file_target(name)
if target is None:
raise ValueError("project root required")
if target.exists():
raise FileExistsError(f"{target} already exists")
target.parent.mkdir(parents=True, exist_ok=True)
class_node = Node(name=name) # placeholder; SceneFile expects a Node-like root
# We don't use from_runtime here -- SceneFile.from_runtime would emit the
# full tree. We need just the class skeleton, so we build it directly.
snippet = (
f"from simvx.core import {base_name}\n"
f"\n"
f"\n"
f"class {name}({base_name}):\n"
f" pass\n"
)
sf = SceneFile.from_source(snippet)
sf.save(target)
# Suppress unused-warning lints; class_node was only retained for future
# extensions (e.g. emitting __init__ stubs).
del class_node
return target
def _create_inline(self, node: Node, name: str, base_name: str) -> Path:
scene_path = self._parent_scene_path()
if scene_path is None:
raise ValueError("parent scene must be saved before inlining")
sf = SceneFile.load(scene_path)
sf.insert_top_level_class(name, base_name)
sf.save()
return scene_path
# ----------------------------------------------------- import / open hook
def _import_new_class(self, source_path: Path, name: str) -> type | None:
"""Import ``source_path`` and resolve ``name`` to a class object."""
# If the new class lives inside the parent scene, the scene file
# is already imported under whatever module name the editor uses.
# For inline mode, the simplest reliable path is to load the scene
# file as a fresh module so the new class object is reachable.
try:
if self._inline_radio is not None and self._inline_radio.selected:
module_name = f"_simvx_make_class_inline_.{source_path.stem}"
else:
module_name = f"_simvx_make_class_.{source_path.stem}"
spec = importlib.util.spec_from_file_location(module_name, str(source_path))
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
except Exception:
log.exception("MakeCustomClass: failed to import %s", source_path)
return None
cls = getattr(module, name, None)
if cls is None or not isinstance(cls, type):
return None
return cls
def _open_in_editor(self, source_path: Path, class_name: str) -> None:
"""Open the new file in the editor's CodeEditorTab.
For inline mode the scene file is already on screen via the scene tab;
we still open it in the code workspace and best-effort jump to the
``class <name>`` line.
"""
if self.state is None:
return
workspace = getattr(self.state, "workspace", None)
if workspace is None or not hasattr(workspace, "open_file"):
return
line: int | None = None
if class_name:
try:
text = source_path.read_text()
except OSError:
text = ""
for i, raw in enumerate(text.splitlines(), start=1):
if raw.lstrip().startswith(f"class {class_name}"):
line = i
break
try:
workspace.open_file(str(source_path), line=line)
except Exception: # noqa: BLE001 - open is best-effort
log.exception("MakeCustomClass: failed to open %s in workspace", source_path)
# ------------------------------------------------------------ rendering
[docs]
def draw(self, renderer):
if not self.visible:
return
# Backdrop covers the whole popup root rect.
x, y, w, h = self.get_global_rect()
renderer.draw_rect((x, y), (w, h), colour=_OVERLAY, filled=True)