Source code for simvx.editor.make_custom_class_dialog

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