"""Inspector Panel -- Property editor for the selected node.
Reads Property descriptors from the selected node's class hierarchy and
creates appropriate editor widgets (Slider, SpinBox, CheckBox, DropDown,
TextEdit, ColourPicker) for each property. All property changes go
through the undo system via PropertyCommand.
Layout:
+----------------------------------+
| TypeName [ Make Custom Class ]|
| Name: [ editable name field ] |
+----------------------------------+
| v Class |
| <module path> |
| <file path:line> [ Edit ] |
+----------------------------------+
| v Instance |
| Visible [x] |
| Position X [ ] Y [ ] Z [ ] |
| speed [=====|-------] 5.0 |
+----------------------------------+
The Class section auto-collapses to a single "Built-in" line when the
node's class lives under ``simvx.core.*`` (the existing "Make Custom
Class" promote button in the header carries the only actionable affordance
in that case). For user-defined subclasses it surfaces an "Edit class
file" button that calls ``state.workspace.open_file(path, line=...)``.
The Instance section header is suppressed when the underlying node has no
displayable property groups (e.g. plain ``Node`` without any descriptors).
The section-building logic lives behind this facade in two private siblings:
``_properties_class_section`` (Class metadata + Make-Custom-Class promotion)
and ``_properties_instance_section`` (per-node editable sections, property
handlers, and section management). They are mixed into ``PropertiesPanel``,
so all methods remain on the class.
"""
from simvx.core import (
Button,
Control,
HBoxContainer,
Label,
Node,
Node2D,
Node3D,
Signal,
TextEdit,
Vec2,
)
from simvx.core.ui.theme import em, get_theme
from ..make_custom_class_dialog import _is_builtin_class
from ..theme import TYPE_COLOUR
from ._properties_class_section import _ClassSectionMixin
from ._properties_instance_section import _InstanceSectionMixin
from .inspector_script import ScriptSectionMixin
from .section_widgets import (
SectionHeader,
font_size,
indent,
label_width,
padding,
row_height,
)
# ============================================================================
# Layout helpers
# ============================================================================
def _row_h() -> float:
return row_height()
def _section_h() -> float:
return em(2.36)
def _label_w() -> float:
return label_width()
def _font_size() -> float:
return font_size()
def _padding() -> float:
return padding()
def _indent() -> float:
return indent()
# ============================================================================
# PropertiesPanel -- Main inspector control
# ============================================================================
[docs]
class PropertiesPanel(_ClassSectionMixin, _InstanceSectionMixin, ScriptSectionMixin, Control):
"""Property editor panel for the currently selected node.
Subscribes to ``state.selection_changed`` and rebuilds its contents
whenever the selection changes. Each Property on the selected node
is mapped to an appropriate editor widget. Property edits are
pushed to the undo stack as ``PropertyCommand`` instances.
Args:
editor_state: The central State instance.
"""
# Emitted as (node, prop_name, old_value, new_value) whenever an edit occurs
property_changed = Signal()
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = get_theme().panel_bg
self.size = Vec2(300, 600)
# Title header widgets
self._type_label: Label | None = None
self._name_edit: TextEdit | None = None
# Section tracking
self._sections: list = []
self._property_widgets: dict[str, Control] = {}
self._scroll_offset = 0.0
# The node we are currently inspecting (cached for refresh)
self._inspected_node: Node | None = None
# Multi-selection: list of all selected nodes (empty for single/no selection)
self._multi_nodes: list[Node] = []
[docs]
def on_ready(self):
"""Connect to editor state signals."""
if self.state is not None:
self.state.selection_changed.connect(self._rebuild_inspector)
self.state.undo_stack.changed.connect(self._refresh_values)
[docs]
def inspect(self, node: Node | None):
"""Public API: display properties for the given node (or clear if None)."""
self._inspected_node = node
self._rebuild()
# ====================================================================
# Rebuild -- called when selection changes
# ====================================================================
def _rebuild_inspector(self):
"""Called by editor state when selection changes."""
if self.state is not None:
sel = self.state.selection
if sel.count > 1:
self._inspected_node = sel.primary
self._multi_nodes = sel.items
else:
self._inspected_node = sel.primary
self._multi_nodes = []
self._rebuild()
def _rebuild(self):
"""Tear down all children and rebuild for the currently inspected node."""
# Clear existing children
for child in list(self.children):
self.remove_child(child)
self._sections.clear()
self._property_widgets.clear()
self._type_label = None
self._name_edit = None
self._scroll_offset = 0.0
self._layout_dirty_ip = True
node = self._inspected_node
if not node:
return
# Multi-select: show shared properties across all selected nodes
if self._multi_nodes and len(self._multi_nodes) > 1:
self._rebuild_multi()
return
# Build the header and property sections
self._add_header(node)
# Class section -- module/file and "Edit class file" link (or compact
# built-in marker when the class ships under simvx.core.*).
self._add_class_section(node)
# Script section (from ScriptSectionMixin)
self._add_script_section(node)
# Mark the instance section so users can tell class metadata from the
# editable per-instance values below. The header auto-hides when the
# node has nothing addressable in the instance area.
instance_anchor_index = len(list(self.children))
self._add_instance_section_header()
# Node properties (name, visibility)
self._add_node_section(node)
# Transform section for spatial nodes
if isinstance(node, Node3D):
self._add_transform3d_section(node)
elif isinstance(node, Node2D):
self._add_transform2d_section(node)
# Custom settings from Property descriptors
self._add_settings_section(node)
# Registry-based sections (mesh, material, audio, collision, camera, particles, etc.)
self._add_registered_sections(node)
# Drop the instance header if no instance-side widgets actually appeared.
if len(list(self.children)) <= instance_anchor_index + 1:
header = list(self.children)[instance_anchor_index]
self.remove_child(header)
self._instance_header = None
# ====================================================================
# Refresh -- called after undo/redo to sync widget values
# ====================================================================
def _refresh_values(self):
"""Sync widget values with the inspected node after undo/redo."""
node = self._inspected_node
if node is None or node is not self.state.selection.primary:
# Selection may have changed out from under us
self._rebuild_inspector()
return
# Refresh name edit
if self._name_edit is not None:
if self._name_edit.text != node.name:
self._name_edit.text = node.name
self._name_edit.cursor_pos = len(node.name)
# ====================================================================
# Header -- type name and editable node name
# ====================================================================
def _add_header(self, node: Node):
"""Add the header showing node type and editable name.
When the selected node's class lives under ``simvx.core.*`` and is the
sole selection, surface a small "Make Custom Class" button so the user
can promote the instance to a user-defined subclass via
:class:`MakeCustomClassDialog`.
"""
# Type label + optional promote button on a single row so the button
# sits next to the type name without consuming a separate inspector row.
header_row = HBoxContainer(name="InspectorHeaderRow")
header_row.size = Vec2(self.size.x, 22)
header_row.separation = 6.0
type_label = Label(type(node).__name__)
type_label.text_colour = TYPE_COLOUR
type_label.font_size = 14.0
type_label.size = Vec2(self.size.x - 160, 22)
header_row.add_child(type_label)
self._type_label = type_label
if (
self.state is not None
and len(self._multi_nodes) <= 1
and _is_builtin_class(type(node))
):
promote_btn = Button("Make Custom Class", name="MakeCustomClassBtn")
promote_btn.font_size = 11.0
promote_btn.size = Vec2(150, 22)
promote_btn.pressed.connect(lambda n=node: self._on_make_custom_class(n))
header_row.add_child(promote_btn)
self._make_custom_class_btn = promote_btn
else:
self._make_custom_class_btn = None
self.add_child(header_row)
# Name edit row
row = HBoxContainer()
row.size = Vec2(self.size.x, _row_h())
row.separation = 4.0
name_label = Label("Name")
name_label.text_colour = get_theme().text_label
name_label.font_size = _font_size()
name_label.size = Vec2(_label_w(), _row_h())
row.add_child(name_label)
name_edit = TextEdit(text=node.name)
name_edit.font_size = _font_size()
name_edit.size = Vec2(self.size.x - _label_w() - _padding() * 2, _row_h())
name_edit.text_submitted.connect(
lambda text: self._on_name_changed(node, text)
)
row.add_child(name_edit)
self._name_edit = name_edit
self.add_child(row)
def _on_name_changed(self, node: Node, new_name: str):
"""Handle node name edit with undo support."""
if not new_name or new_name == node.name:
return
old_name = node.name
self._on_property_changed(node, "name", old_name, new_name)
# ====================================================================
# Instance section header -- visual divider before per-node values
# ====================================================================
def _add_instance_section_header(self) -> None:
"""Add a non-collapsible 'Instance' header before instance widgets.
The header is removed by :meth:`_rebuild` if no instance-side widgets
end up below it (avoids a stranded header above an empty area for
plain ``Node`` instances with no Properties).
"""
header = SectionHeader("Instance", collapsed=False, name="InstanceSectionHeader")
header.size = Vec2(self.size.x, _section_h())
# Disable interactive collapse: the header is purely a marker because
# the underlying sections (Node, Transform, Properties, ...) have
# their own collapse toggles.
header.mouse_filter = False
self.add_child(header)
self._instance_header = header
# ====================================================================
# Layout -- vertically stack all children
# ====================================================================
[docs]
def on_process(self, dt: float):
"""Reflow vertical layout when size changes or content is dirty."""
current_size = (self.size.x, self.size.y)
if getattr(self, "_layout_dirty_ip", True) or current_size != getattr(self, "_last_size_ip", None):
self._last_size_ip = current_size
self._layout_dirty_ip = False
self._layout_children()
def _layout_children(self):
"""Stack all visible children vertically with padding."""
from simvx.core.ui.containers import Container
_place = Container._place
pad = _padding()
content_w = self.size.x - pad * 2
y = pad
for child in self.children:
if not isinstance(child, Control):
continue
if not child.visible:
continue
_place(child, pad, y, content_w, child.size.y)
y += child.size.y + 2
# ====================================================================
# Drawing
# ====================================================================
[docs]
def on_draw(self, renderer):
t = get_theme()
x, y, w, h = self.get_global_rect()
# Panel background
renderer.draw_rect((x, y), (w, h), colour=t.panel_bg, filled=True)
# Left border accent
renderer.draw_rect((x, y), (2, h), colour=t.border, filled=True)
# Title bar separator
if self._inspected_node is not None:
sep_y = y + _padding() + 22 + _row_h() + 2
renderer.draw_rect((x + 4, sep_y), (w - 8, 1), colour=t.border, filled=True)
def _draw_recursive(self, renderer):
"""Override to wrap child traversal in a clip region."""
if not self.visible:
return
self.on_draw(renderer)
x, y, w, h = self.get_global_rect()
renderer.push_clip(x, y, w, h)
for child in list(self.children):
child._draw_recursive(renderer)
renderer.pop_clip()