"""Inspector script section -- script attachment and embedding UI.
Provides methods for building the script section of the inspector:
attach, detach, embed, and open script for the selected node.
The class-source metadata (module, file path, "Edit class file" link)
is rendered by ``PropertiesPanel._add_class_section`` -- the legacy
``_add_class_source_info`` shim is now a no-op kept only so any mixin
overrides keep working unchanged.
These are mixed into ``PropertiesPanel`` via ``ScriptSectionMixin``.
"""
from pathlib import Path
from simvx.core import (
Button,
HBoxContainer,
Label,
Node,
SizingMode,
)
from simvx.core.script import parse_script_ref
from simvx.core.ui.theme import em, get_theme
def _row_h() -> float:
return em(2.18)
def _font_size() -> float:
return get_theme().font_size
[docs]
class ScriptSectionMixin:
"""Mixin that adds script section methods to PropertiesPanel.
Expects the host class to provide:
- ``self.state``: State or None
- ``self.add_child(child)``: add a child control
- ``self._rebuild()``: full inspector rebuild
"""
def _add_script_section(self, node: Node):
"""Add script attachment section and class source info below the header."""
# Class source info -- show which Python file defines this node's class
self._add_class_source_info(node)
if node.script:
# File-backed script -- show path + Open / Detach buttons
display_path, _ = parse_script_ref(node.script)
row = HBoxContainer()
row.size = self._script_row_size()
row.sizing = SizingMode.EXPAND
row.separation = 4.0
path_label = Label(display_path)
path_label.text_colour = (0.6, 0.8, 1.0, 1.0)
path_label.font_size = _font_size()
path_label.stretch_ratio = 4.0
row.add_child(path_label)
open_btn = Button("Open")
open_btn.font_size = _font_size()
open_btn.stretch_ratio = 1.0
open_btn.pressed.connect(lambda: self._on_open_script(node))
row.add_child(open_btn)
detach_btn = Button("Detach")
detach_btn.font_size = _font_size()
detach_btn.stretch_ratio = 1.0
detach_btn.pressed.connect(lambda: self._on_detach_script(node))
row.add_child(detach_btn)
self.add_child(row)
elif getattr(node, "_script_embedded", None):
# Embedded script -- show Open and Detach
row = HBoxContainer()
row.sizing = SizingMode.EXPAND
row.separation = 4
embed_label = Label(f"{node.name} (embedded)")
embed_label.text_colour = (0.6, 0.9, 0.6, 1.0)
embed_label.font_size = _font_size()
embed_label.stretch_ratio = 4.0
row.add_child(embed_label)
open_btn = Button("Open")
open_btn.font_size = _font_size()
open_btn.stretch_ratio = 1.0
open_btn.pressed.connect(lambda: self._on_open_script(node))
row.add_child(open_btn)
detach_btn = Button("Detach")
detach_btn.font_size = _font_size()
detach_btn.stretch_ratio = 1.0
detach_btn.pressed.connect(lambda: self._on_detach_embedded(node))
row.add_child(detach_btn)
self.add_child(row)
else:
# No script -- show Attach and Embed buttons
row = HBoxContainer()
row.sizing = SizingMode.FILL
row.separation = 4
attach_btn = Button("Attach Script")
attach_btn.font_size = _font_size()
attach_btn.pressed.connect(lambda: self._on_attach_script(node))
row.add_child(attach_btn)
self._attach_script_btn = attach_btn
embed_btn = Button("Embed Script")
embed_btn.font_size = _font_size()
embed_btn.pressed.connect(lambda: self._on_embed_script(node))
row.add_child(embed_btn)
self.add_child(row)
def _add_class_source_info(self, node: Node):
"""Class source info is now surfaced by ``_add_class_section``.
Kept as a no-op so the script-section flow stays unchanged for any
third-party mixin overrides; the canonical class metadata (module,
file path, "Edit class file" link) lives in the Class section
rendered by :meth:`PropertiesPanel._add_class_section`.
"""
return
def _script_row_size(self):
from simvx.core import Vec2
return Vec2(self.size.x, _row_h())
def _on_open_script(self, node: Node):
"""Open the node's script in a workspace tab."""
if self.state:
self.state.selection.select(node)
self.state.workspace.open_script(
node, project_path_fn=lambda: self.state.project_path
)
def _on_detach_script(self, node: Node):
"""Detach the script from the node."""
if self.state:
self.state.detach_script(node)
self._rebuild()
def _on_attach_script(self, node: Node):
"""Open a popup menu offering a new template script or an existing file."""
if not self.state:
return
from simvx.core.ui.menu import MenuItem, PopupMenu
menu = getattr(self, "_attach_menu", None)
if menu is None:
menu = PopupMenu(name="AttachScriptMenu")
self._attach_menu = menu
# ``_rebuild`` removes every panel child (including a previously cached
# menu) after each attach. Re-parent the cached menu so it lives in the
# tree again before showing -- otherwise ``show`` cannot register its
# modal scope and the menu is invisible to input routing.
if menu.parent is not self:
self.add_child(menu)
menu.items = [
MenuItem("New script from template", callback=lambda: self._create_template_script(node)),
MenuItem("Attach existing file...", callback=lambda: self._attach_existing_script(node)),
]
btn = getattr(self, "_attach_script_btn", None)
if btn is not None:
gx, gy, _, gh = btn.get_global_rect()
menu.show(gx, gy + gh)
else:
menu.show(0, 0)
def _create_template_script(self, node: Node):
"""Create and attach a new template-backed script file to the node."""
if not self.state:
return
template_name, class_name = self._resolve_template(node)
# snake_case for file
snake = ""
for i, ch in enumerate(node.name):
if ch.isupper() and i > 0 and node.name[i - 1].islower():
snake += "_"
snake += ch.lower()
snake = snake.replace(" ", "_")
rel_path = f"scripts/{snake}.py"
abs_path = self.state.create_script(node, template_name, class_name, rel_path)
if abs_path:
self._rebuild()
self._on_open_script(node)
def _attach_existing_script(self, node: Node):
"""Open the editor's FileDialog to attach an existing .py file to the node."""
dialog = getattr(self.state, "_file_dialog", None)
if dialog is None:
return
dialog.file_selected.clear()
dialog.file_selected.connect(lambda chosen: self._on_existing_script_chosen(node, chosen))
scripts_dir = None
if self.state.project_path is not None:
scripts_dir = str(Path(self.state.project_path) / "scripts")
if not Path(scripts_dir).is_dir():
scripts_dir = str(self.state.project_path)
dialog.show(mode="open", path=scripts_dir, filter="*.py")
def _on_existing_script_chosen(self, node: Node, chosen: str):
"""Attach the selected file to *node*, resolving its Node subclass name."""
from simvx.core.script import _find_node_subclass_in_source
chosen_path = Path(chosen)
base = self.state.project_path
if base is not None:
try:
rel_path = chosen_path.relative_to(Path(base)).as_posix()
except ValueError:
rel_path = chosen_path.as_posix()
else:
rel_path = chosen_path.as_posix()
class_name = ""
try:
class_name = _find_node_subclass_in_source(chosen_path.read_text()) or ""
except OSError:
class_name = ""
self.state.attach_script(node, rel_path, class_name)
self._rebuild()
self._on_open_script(node)
def _on_embed_script(self, node: Node):
"""Create and embed a script directly in the node."""
if not self.state:
return
template_name, class_name = self._resolve_template(node)
from simvx.editor.templates import generate_script_text
source = generate_script_text(template_name, class_name)
node._script_embedded = source
self.state.modified = True
self.state.script_changed.emit()
self._rebuild()
self._on_open_script(node)
def _on_detach_embedded(self, node: Node):
"""Detach an embedded script from the node."""
node._script_embedded = None
if self.state:
self.state.modified = True
self.state.script_changed.emit()
self._rebuild()
def _resolve_template(self, node: Node) -> tuple[str, str]:
"""Determine the best template name and class name for a node."""
from simvx.editor.templates import TEMPLATES
template_name = "Node"
for name in (type(node).__name__, *[b.__name__ for b in type(node).__mro__[1:]]):
if name in TEMPLATES:
template_name = name
break
class_name = node.name.replace(" ", "").replace("_", "")
if not class_name[0:1].isupper():
class_name = class_name.capitalize()
return template_name, class_name