"""Convert to Custom Class -- give a node a class of its own, and bind the scene to it.
Opens from the scene tree's context menu and from the Inspector header when the
selected node's class is one the engine ships. The user picks:
* a class name (defaults to the node's ``name``);
* a template, which is the code the class is written with -- ``Empty`` for a
class with nothing in it, or one of :data:`~simvx.editor.templates.TEMPLATES`
whose base the node already descends from;
* where the file goes: a new file under ``[editor].class_files_dir``, whose path
the user may edit, or the scene file itself.
On submit :func:`~simvx.editor.convert_to_class.convert_node_to_class` writes
the class, points the scene's own source at it, saves through the plan/commit
path and reloads, so the node in the editor afterwards IS an instance of the new
class -- built by running it, with whatever the template declared already there.
The new file is then opened in the code workspace, at the class it defines. What
the save could not do beyond the conversion goes to the editor's save-report
card, which is where a save that changes the author's own file says so.
Name collisions are detected up-front via :class:`ProjectClassIndex`, and so are
the conditions the conversion would refuse for: a scene that has never been
saved has no source to bind to.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import (
Button,
DropDown,
HBoxContainer,
Label,
Node,
Panel,
Property,
RadioButton,
Signal,
TextEdit,
VBoxContainer,
Vec2,
)
from .convert_to_class import (
EMPTY_TEMPLATE,
ConversionRefused,
convert_node_to_class,
default_class_file,
templates_for,
)
from .project_classes import ProjectClassIndex
if TYPE_CHECKING:
from .convert_to_class import Conversion
from .state import State
log = logging.getLogger(__name__)
__all__ = ["MakeCustomClassDialog", "snake_case"]
_DIALOG_W = 520.0
_DIALOG_H = 340.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 converts a node into a user class of its own.
Construct once per editor; call :meth:`show_for` with the target node each
time the action 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
# Raised by ``open_for``, not by construction, and drawn above the panels it
# covers. Declared rather than assigned after the gate, so a caller's
# keyword survives.
visible = Property(
False,
coerce=bool,
hint="Whether this node and its subtree are drawn and picked",
on_change="_on_visible_changed",
)
z_index = Property(1700, range=(-4096, 4096), hint="Draw order (higher = on top)", on_change="_invalidate_z_cache")
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._target_node: Node | None = None
self._project_index: ProjectClassIndex | None = None
#: Has the user typed a destination of their own? Until they do, the
#: field follows the class name.
self._file_edited = False
self._inner: Panel | None = None
self._title: Label | None = None
self._subtitle: Label | None = None
self._name_edit: TextEdit | None = None
self._template_drop: DropDown | None = None
self._templates: list[str] = [EMPTY_TEMPLATE]
self._new_file_radio: RadioButton | None = None
self._inline_radio: RadioButton | None = None
self._file_edit: TextEdit | 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("Convert to 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)
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)
vbox.add_child(self._labelled("Class name", self._name_edit, "NameRow"))
self._template_drop = DropDown(items=list(self._templates), selected_index=0, name="TemplateDrop")
self._template_drop.size = Vec2(_FIELD_W, _ROW_H)
self._template_drop.item_selected.connect(self._on_template_changed)
vbox.add_child(self._labelled("Template", self._template_drop, "TemplateRow"))
# 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._file_edit = TextEdit(name="ClassFileEdit")
self._file_edit.size = Vec2(_FIELD_W, _ROW_H)
self._file_edit.text_changed.connect(self._on_file_changed)
vbox.add_child(self._labelled("File", self._file_edit, "FileRow"))
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("Convert", 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)
def _labelled(self, text: str, field, name: str) -> HBoxContainer:
"""One form row: a label of the shared width, then the field."""
row = HBoxContainer(name=name)
row.separation = 8
row.size = Vec2(_DIALOG_W - 2 * _PAD, _ROW_H)
label = Label(text, name=f"{name}Label")
label.font_size = 13.0
label.text_colour = _LABEL
label.size = Vec2(_LABEL_W, _ROW_H)
row.add_child(label)
row.add_child(field)
return row
# ------------------------------------------------------------ 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
self._file_edited = False
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._template_drop is not None and node is not None:
self._templates = templates_for(node)
self._template_drop.items = list(self._templates)
self._template_drop.selected_index = 0
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 = self._describe(node, base_name)
if self._error_label is not None:
self._error_label.text = ""
self._refresh_destination()
# 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
# Register as a blocking overlay so it scopes input + dims (the editor
# stays live behind it); replaces the bare z_index stacking. Declare the
# name field as the initial focus so typing lands there on open.
self.show_overlay("blocking", initial_focus=self._name_edit)
self._validate()
[docs]
def hide_dialog(self) -> None:
"""Hide the dialog without emitting a result."""
self.close_overlay()
self.visible = False
[docs]
@property
def template(self) -> str:
"""The template the class will be written with."""
if self._template_drop is None:
return EMPTY_TEMPLATE
index = self._template_drop.selected_index
return self._templates[index] if 0 <= index < len(self._templates) else EMPTY_TEMPLATE
[docs]
@property
def destination(self) -> Path | None:
"""The file the class will be written to, as the form currently reads."""
if self._inline_radio is not None and self._inline_radio.selected:
return self._parent_scene_path()
text = (self._file_edit.text if self._file_edit else "").strip()
return Path(text) if text else None
# ------------------------------------------------------------ internals
def _describe(self, node: Node | None, base_name: str) -> str:
"""The one line under the title: what this conversion is about to do."""
if node is None:
return ""
scene = getattr(self.state, "edited_scene", None) if self.state is not None else None
if scene is not None and node is scene.root:
return f"{node.name} (the scene's own class extends the new one)"
return f"{node.name} (extends {base_name})"
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 _refresh_destination(self) -> None:
"""Put the destination the form implies into the file field.
A path the user typed is theirs and is never written over; everything
else follows the class name, the way a save dialog's filename does.
"""
if self._file_edit is None:
return
if self._inline_radio is not None and self._inline_radio.selected:
scene = self._parent_scene_path()
self._file_edit.text = str(scene) if scene else ""
return
if self._file_edited:
return
name = (self._name_edit.text if self._name_edit else "").strip()
target = default_class_file(self.state, name) if self.state is not None else None
self._file_edit.text = str(target) if target is not None else ""
def _on_name_changed(self, _text: str) -> None:
self._refresh_destination()
self._validate()
def _on_template_changed(self, _index: int) -> None:
self._validate()
def _on_location_changed(self, _selected: bool) -> None:
self._refresh_destination()
self._validate()
def _on_file_changed(self, text: str) -> None:
inline = self._inline_radio is not None and self._inline_radio.selected
scene = self._parent_scene_path()
expected = str(scene) if inline and scene else None
if expected is None or text.strip() != expected:
self._file_edited = True
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._parent_scene_path() is None:
return "Save the scene before converting a node to a class"
scene = getattr(self.state, "edited_scene", None) if self.state is not None else None
root = scene.root if scene is not None else None
if root is not None and self._target_node is not root and self._target_node.parent is not root:
return "Only the scene root and its own children can be converted"
if self.destination is None:
return "Choose a file for the class"
if self._project_path() is None and not (self._inline_radio and self._inline_radio.selected):
return "Project root required for new file"
# 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 ""
try:
result = convert_node_to_class(
self.state,
node,
name,
destination=self.destination,
template=self.template,
)
except ConversionRefused as refused:
log.error("Convert to Custom Class: %s", refused)
if self._error_label is not None:
self._error_label.text = str(refused)
return
except Exception as exc: # noqa: BLE001 - user-facing error path
log.exception("Convert to Custom Class: the conversion failed")
if self._error_label is not None:
self._error_label.text = f"Error: {exc}"
return
self._open_in_editor(result.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(result.node, type(result.node) if result.node is not None else None, result.source_path)
self.hide_dialog()
self._report(result)
def _report(self, result: Conversion) -> None:
"""Say what the conversion could not do, where the user will see it.
A conversion is a save, so its report is a save's report and goes where
one goes: every entry to the log, and a destructive one -- a statement
the save could not carry, or a class file written that the scene was
never pointed at -- to the editor's report dialog
(:meth:`~simvx.editor.save_report_dialog.SaveReportDialog.show_written`).
Closing on one of those with nothing but a log line is this dialog
telling the user it worked.
Shown after the dialog closes, since it is about a conversion that
happened. A session with no report dialog wired -- headless, or a test
driving the dialog directly -- has the log, as every other save does.
"""
for entry in result.report:
log.error("%s: %s", result.source_path.name, entry)
dialog = getattr(self.state, "_save_report_dialog", None) if self.state is not None else None
if dialog is None or not any(entry.destructive for entry in result.report):
return
dialog.show_written(self._parent_scene_path() or result.source_path, result.report)
# ----------------------------------------------------- import / open hook
def _open_in_editor(self, source_path: Path, class_name: str) -> None:
"""Open the new file in the editor's CodeEditorTab.
For the inline destination 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(encoding="utf-8")
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("Convert to Custom Class: failed to open %s in workspace", source_path)
# ------------------------------------------------------------ rendering
[docs]
def on_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)