"""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).
"""
import inspect as _stdlib_inspect
import math
from collections import OrderedDict
from pathlib import Path
from typing import Any
from simvx.core import (
Button,
CheckBox,
ColourPicker,
Control,
DropDown,
HBoxContainer,
Label,
Node,
Node2D,
Node3D,
Property,
PropertyCommand,
Quat,
Signal,
Slider,
SpinBox,
TextEdit,
ThemeColour,
Vec2,
Vec3,
WorldEnvironment,
)
from simvx.core.ui.theme import em, get_theme
from ..make_custom_class_dialog import _is_builtin_class
from ..theme import TYPE_COLOUR
from .anchor_preset_widget import AnchorPresetButton
from .inspector_script import ScriptSectionMixin
from .inspector_widgets import build_widget
from .section_widgets import (
BitmaskRow,
NodePathPicker,
PropertyRow,
QuatEulerRow,
Section,
SectionHeader,
VectorRow,
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(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[Section] = []
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 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
# ====================================================================
# Multi-select rebuild
# ====================================================================
def _rebuild_multi(self):
"""Build inspector UI showing shared properties across multiple selected nodes."""
nodes = self._multi_nodes
count = len(nodes)
# Header: show count and common type
type_names = {type(n).__name__ for n in nodes}
if len(type_names) == 1:
header_text = f"{count} {next(iter(type_names))} nodes selected"
else:
header_text = f"{count} nodes selected"
type_label = Label(header_text)
type_label.text_colour = TYPE_COLOUR
type_label.font_size = 14.0
type_label.size = Vec2(self.size.x, 22)
self.add_child(type_label)
self._type_label = type_label
# Visibility toggle (shared by all nodes)
rows: list[Control] = []
cb = CheckBox("", checked=all(n.visible for n in nodes))
cb.toggled.connect(lambda checked: self._on_multi_property_changed("visible", not checked, checked))
row = PropertyRow("Visible", cb)
rows.append(row)
self._property_widgets["visible"] = cb
self._add_section("Node", rows)
# Collect shared Property descriptors across all selected nodes
shared = self._collect_shared_properties(nodes)
if shared:
settings_rows: list[Control] = []
for name, prop in shared.items():
if name in ("visible", "gizmo_colour"):
continue
# Use primary node's value as displayed value
value = getattr(nodes[0], name)
widget = self._create_widget_for_multi_property(nodes, name, prop, value)
if widget is not None:
r = PropertyRow(name, widget)
settings_rows.append(r)
self._property_widgets[name] = widget
if settings_rows:
self._add_section("Shared Properties", settings_rows)
def _collect_shared_properties(self, nodes: list[Node]) -> dict[str, Property]:
"""Return Property descriptors that exist on all nodes in the list."""
if not nodes:
return {}
# Start with properties from first node
first_props = self._collect_properties(nodes[0])
shared: dict[str, Property] = {}
for name, prop in first_props.items():
if not isinstance(prop, Property):
continue
if all(hasattr(n, name) and self._has_property_descriptor(n, name) for n in nodes[1:]):
shared[name] = prop
return shared
@staticmethod
def _has_property_descriptor(node: Node, name: str) -> bool:
"""Check if a node's class has a Property descriptor for the given name."""
for cls in type(node).__mro__:
if name in cls.__dict__ and isinstance(cls.__dict__[name], Property):
return True
return False
def _create_widget_for_multi_property(
self, nodes: list[Node], name: str, setting: Property, value,
) -> Control | None:
"""Create a widget that updates all selected nodes when edited."""
# Reuse single-node widget creation with a multi-node change handler
widget = self._create_widget_for_property(nodes[0], name, setting, value)
if widget is None:
return None
# Rewire callbacks to update all nodes
self._rewire_multi_callbacks(widget, nodes, name, setting, value)
return widget
def _rewire_multi_callbacks(self, widget, nodes, name, setting, value):
"""Disconnect single-node callbacks and connect multi-node callbacks."""
if isinstance(widget, CheckBox):
widget.toggled._callbacks.clear()
widget.toggled.connect(
lambda checked, n=name: self._on_multi_property_changed(n, not checked, checked))
elif isinstance(widget, Slider):
widget.value_changed._callbacks.clear()
is_int = isinstance(value, int) and not isinstance(value, bool)
widget.value_changed.connect(
lambda val, n=name, ii=is_int: self._on_multi_property_changed(
n, getattr(nodes[0], n), int(val) if ii else val))
elif isinstance(widget, SpinBox):
widget.value_changed._callbacks.clear()
is_int = isinstance(value, int) and not isinstance(value, bool)
widget.value_changed.connect(
lambda val, n=name, ii=is_int: self._on_multi_property_changed(
n, getattr(nodes[0], n), int(val) if ii else val))
elif isinstance(widget, TextEdit):
widget.text_submitted._callbacks.clear()
widget.text_submitted.connect(
lambda text, n=name: self._on_multi_property_changed(n, getattr(nodes[0], n), text))
elif isinstance(widget, DropDown):
widget.item_selected._callbacks.clear()
widget.item_selected.connect(
lambda idx, n=name, s=setting: self._on_multi_property_changed(
n, getattr(nodes[0], n), s.enum[idx]))
elif isinstance(widget, ColourPicker):
widget.colour_changed._callbacks.clear()
has_alpha = getattr(widget, "has_alpha", True)
widget.colour_changed.connect(
lambda colour, n=name, ha=has_alpha: self._on_multi_property_changed(
n, getattr(nodes[0], n),
tuple(colour) if ha else tuple(colour[:3])))
elif isinstance(widget, BitmaskRow):
widget.value_changed._callbacks.clear()
widget.value_changed.connect(
lambda new_val, n=name: self._on_multi_property_changed(
n, int(getattr(nodes[0], n)), int(new_val)))
elif isinstance(widget, QuatEulerRow):
widget.value_changed._callbacks.clear()
widget.value_changed.connect(
lambda new_q, n=name: self._on_multi_property_changed(
n, Quat(getattr(nodes[0], n)), new_q))
elif isinstance(widget, NodePathPicker):
widget.path_selected._callbacks.clear()
widget.cleared._callbacks.clear()
widget.path_selected.connect(
lambda path, n=name: self._on_multi_property_changed(
n, getattr(nodes[0], n), path))
widget.cleared.connect(
lambda n=name: self._on_multi_property_changed(
n, getattr(nodes[0], n), ""))
def _on_multi_property_changed(self, prop: str, old_val, new_val):
"""Push property commands for all selected nodes."""
if old_val == new_val:
return
for node in self._multi_nodes:
node_old = getattr(node, prop)
if self.state is not None:
cmd = PropertyCommand(
node, prop, node_old, new_val,
description=f"Set {node.name}.{prop}",
)
self.state.undo_stack.push(cmd)
else:
setattr(node, prop, new_val)
if self.state is not None:
self.state.modified = True
# ====================================================================
# 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_make_custom_class(self, node: Node) -> None:
"""Open the Make Custom Class dialog targeting ``node``.
Looks up the dialog from :attr:`State.workspace`-adjacent state via the
editor :class:`Root`; falls back to constructing the dialog inline when
the host doesn't expose one (test harnesses, headless tools).
"""
if self.state is None:
return
dialog = getattr(self.state, "_make_custom_class_dialog", None)
if dialog is None:
from ..make_custom_class_dialog import MakeCustomClassDialog
dialog = MakeCustomClassDialog(state=self.state, name="MakeCustomClassDialog")
self.state._make_custom_class_dialog = dialog
# When the inspector is mounted in a real editor the Root owns the
# popup layer; tests parent it to the panel itself so the harness
# can see it.
host: Node | None = None
root = self.find_root() if hasattr(self, "find_root") else None
host = root if isinstance(root, Node) else self
host.add_child(dialog)
# Best-effort wire to project class index so collision checks work.
if dialog._project_index is None:
project_index = self._lookup_project_class_index()
if project_index is not None:
dialog.set_project_index(project_index)
# Compute parent size for centering: prefer the scene tree's screen
# size when available, fall back to this panel's size.
parent_size: Vec2 | None = None
tree = getattr(self, "_tree", None)
if tree is not None and getattr(tree, "screen_size", None) is not None:
parent_size = tree.screen_size
elif self.size.x > 0 and self.size.y > 0:
parent_size = self.size
dialog.show_for(node, parent_size=parent_size)
def _lookup_project_class_index(self):
"""Locate a :class:`ProjectClassIndex` from a sibling SceneTreePanel."""
tree = getattr(self, "_tree", None)
root = tree.root if tree is not None else None
if root is None:
return None
try:
from .scene_tree.panel import SceneTreePanel
panel = root.find(SceneTreePanel) if hasattr(root, "find") else None
except Exception: # noqa: BLE001 - defensive: editor wiring varies
return None
return getattr(panel, "_project_class_index", None) if panel is not None else None
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)
# ====================================================================
# Class section -- module/file metadata + "Edit class file" link
# ====================================================================
def _add_class_section(self, node: Node) -> None:
"""Add a small Class section showing module/file + an Edit link.
For built-in classes (``simvx.core.*``) the Class section collapses to
a single one-line "Built-in" pill -- the actionable affordance lives
in the header's "Make Custom Class" button (see :meth:`_add_header`).
For user-defined subclasses we surface the dotted module path, the
absolute file path with the ``class X`` line number, and an
"Edit class file" button that routes to
``state.workspace.open_file(path, line=...)``.
"""
cls = type(node)
module = cls.__module__ or ""
if not module or module == "builtins":
self._class_section_state = "skipped"
self._edit_class_file_btn = None
return
rows: list[Control] = []
is_builtin = _is_builtin_class(cls)
type_label = Label(cls.__name__)
type_label.text_colour = TYPE_COLOUR
type_label.font_size = _font_size()
rows.append(PropertyRow("Type", type_label))
if is_builtin:
kind_label = Label("Built-in (simvx.core)")
kind_label.text_colour = get_theme().text_label
kind_label.font_size = _font_size()
rows.append(PropertyRow("Kind", kind_label))
self._edit_class_file_btn = None
self._class_section_state = "builtin"
else:
module_label = Label(module)
module_label.text_colour = get_theme().text_label
module_label.font_size = _font_size()
rows.append(PropertyRow("Module", module_label))
file_path, line_no = self._resolve_class_source(cls)
if file_path is not None:
display = str(file_path)
if line_no is not None:
display = f"{display}:{line_no}"
file_label = Label(display)
file_label.text_colour = (0.6, 0.8, 1.0, 1.0)
file_label.font_size = _font_size() * 0.9
rows.append(PropertyRow("File", file_label))
edit_btn = Button("Edit class file", name="EditClassFileBtn")
edit_btn.font_size = 11.0
edit_btn.size = Vec2(150, 22)
edit_btn.pressed.connect(
lambda p=file_path, ln=line_no: self._on_edit_class_file(p, ln)
)
rows.append(PropertyRow("", edit_btn))
self._edit_class_file_btn = edit_btn
self._class_section_state = "user"
else:
self._edit_class_file_btn = None
self._class_section_state = "user-no-file"
self._add_section("Class", rows)
def _resolve_class_source(self, cls: type) -> tuple[Path | None, int | None]:
"""Return ``(file_path, line_no)`` for the ``class <Name>`` definition.
The project class index is consulted first so files relocated between
the editor session start and now still resolve correctly. Falls back
to :func:`inspect.getsourcelines` for classes the index doesn't know
about (e.g. user packages installed in editable mode but outside the
project ``src/`` tree).
"""
index = self._lookup_project_class_index()
if index is not None:
for entry in index.all():
if entry.name == cls.__name__ and entry.module_path == (cls.__module__ or ""):
line_no = self._find_class_line(entry.file_path, cls.__name__)
return entry.file_path, line_no
try:
file_str = _stdlib_inspect.getfile(cls)
except (TypeError, OSError):
return None, None
try:
_, line_no = _stdlib_inspect.getsourcelines(cls)
except (OSError, TypeError):
line_no = None
return Path(file_str), line_no
@staticmethod
def _find_class_line(file_path: Path, class_name: str) -> int | None:
"""Best-effort scan for the ``class <name>`` line number in a file."""
try:
text = file_path.read_text(encoding="utf-8")
except OSError:
return None
target = f"class {class_name}"
for i, line in enumerate(text.splitlines(), start=1):
stripped = line.lstrip()
if stripped.startswith(target) and (
len(stripped) == len(target)
or stripped[len(target)] in "(:"
or stripped[len(target)].isspace()
):
return i
return None
def _on_edit_class_file(self, file_path: Path, line_no: int | None) -> None:
"""Route Edit-class-file clicks to the workspace's CodeEditorPanel."""
if self.state is None:
return
workspace = getattr(self.state, "workspace", None)
if workspace is None or not hasattr(workspace, "open_file"):
return
try:
workspace.open_file(str(file_path), line=line_no)
except Exception: # noqa: BLE001 - open is best-effort
pass
# ====================================================================
# 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
# ====================================================================
# Node section -- visibility
# ====================================================================
def _add_node_section(self, node: Node):
"""Add section for base Node properties (visibility)."""
rows: list[Control] = []
# Visible toggle
cb = CheckBox("", checked=node.visible)
cb.toggled.connect(
lambda checked: self._on_property_changed(
node, "visible", not checked, checked)
)
row = PropertyRow("Visible", cb)
rows.append(row)
self._property_widgets["visible"] = cb
self._add_section("Node", rows)
# ====================================================================
# Transform sections
# ====================================================================
def _add_transform3d_section(self, node: Node3D):
"""Add position, rotation (euler degrees), and scale for 3D nodes."""
rows: list[Control] = []
# Position
pos = node.position
pos_vals = (
pos.x if hasattr(pos, 'x') else float(pos[0]),
pos.y if hasattr(pos, 'y') else float(pos[1]),
pos.z if hasattr(pos, 'z') else float(pos[2]),
)
pos_row = VectorRow("Position", 3, pos_vals, step=0.1)
for i, spin in enumerate(pos_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis: self._on_transform3d_pos(node, ax, val)
)
rows.append(pos_row)
self._property_widgets["position"] = pos_row
# Rotation (displayed as euler degrees, stored as Quat in radians)
euler_rad = node.rotation.euler_angles()
rot_vals = (
math.degrees(euler_rad.x),
math.degrees(euler_rad.y),
math.degrees(euler_rad.z),
)
rot_row = VectorRow("Rotation", 3, rot_vals, step=1.0,
min_val=-360, max_val=360)
for i, spin in enumerate(rot_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis: self._on_transform3d_rot(node, ax, val)
)
rows.append(rot_row)
self._property_widgets["rotation"] = rot_row
# Scale
scl = node.scale
scl_vals = (
scl.x if hasattr(scl, 'x') else float(scl[0]),
scl.y if hasattr(scl, 'y') else float(scl[1]),
scl.z if hasattr(scl, 'z') else float(scl[2]),
)
scl_row = VectorRow("Scale", 3, scl_vals, step=0.1,
min_val=-100, max_val=100)
for i, spin in enumerate(scl_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis: self._on_transform3d_scl(node, ax, val)
)
rows.append(scl_row)
self._property_widgets["scale"] = scl_row
self._add_section("Transform", rows)
def _on_transform3d_pos(self, node: Node3D, axis: int, value: float):
"""Handle position component change with undo."""
old_pos = Vec3(node.position)
new_vals = [old_pos.x, old_pos.y, old_pos.z]
new_vals[axis] = value
new_pos = Vec3(new_vals[0], new_vals[1], new_vals[2])
self._on_property_changed(node, "position", old_pos, new_pos)
def _on_transform3d_rot(self, node: Node3D, axis: int, value: float):
"""Handle rotation euler component change with undo."""
old_rot = Quat(node.rotation)
old_euler_rad = node.rotation.euler_angles()
new_euler_vals = [math.degrees(old_euler_rad.x), math.degrees(old_euler_rad.y), math.degrees(old_euler_rad.z)]
new_euler_vals[axis] = value
new_rot = Quat.from_euler(
math.radians(new_euler_vals[0]), math.radians(new_euler_vals[1]), math.radians(new_euler_vals[2]))
self._on_property_changed(node, "rotation", old_rot, new_rot)
def _on_transform3d_scl(self, node: Node3D, axis: int, value: float):
"""Handle scale component change with undo."""
old_scl = Vec3(node.scale)
new_vals = [old_scl.x, old_scl.y, old_scl.z]
new_vals[axis] = value
new_scl = Vec3(new_vals[0], new_vals[1], new_vals[2])
self._on_property_changed(node, "scale", old_scl, new_scl)
def _add_transform2d_section(self, node: Node2D):
"""Add position, rotation (degrees), and scale for 2D nodes."""
rows: list[Control] = []
# Position (Vec2)
pos = node.position
pos_vals = (
pos.x if hasattr(pos, 'x') else float(pos[0]),
pos.y if hasattr(pos, 'y') else float(pos[1]),
)
pos_row = VectorRow("Position", 2, pos_vals, step=0.1)
for i, spin in enumerate(pos_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis: self._on_transform2d_pos(node, ax, val)
)
rows.append(pos_row)
self._property_widgets["position"] = pos_row
# Rotation (display degrees, store radians)
rot_spin = SpinBox(min_val=-360, max_val=360,
value=math.degrees(node.rotation), step=1.0)
rot_spin.font_size = 11.0
rot_spin.value_changed.connect(
lambda val: self._on_property_changed(
node, "rotation", node.rotation, math.radians(val))
)
rot_row = PropertyRow("Rotation", rot_spin)
rows.append(rot_row)
self._property_widgets["rotation"] = rot_spin
# Scale (Vec2)
scl = node.scale
scl_vals = (
scl.x if hasattr(scl, 'x') else float(scl[0]),
scl.y if hasattr(scl, 'y') else float(scl[1]),
)
scl_row = VectorRow("Scale", 2, scl_vals, step=0.1,
min_val=-100, max_val=100)
for i, spin in enumerate(scl_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis: self._on_transform2d_scl(node, ax, val)
)
rows.append(scl_row)
self._property_widgets["scale"] = scl_row
self._add_section("Transform", rows)
def _on_transform2d_pos(self, node: Node2D, axis: int, value: float):
"""Handle 2D position component change with undo."""
old_pos = Vec2(node.position)
new_pos = Vec2(value, old_pos.y) if axis == 0 else Vec2(old_pos.x, value)
self._on_property_changed(node, "position", old_pos, new_pos)
def _on_transform2d_scl(self, node: Node2D, axis: int, value: float):
"""Handle 2D scale component change with undo."""
old_scl = Vec2(node.scale)
new_scl = Vec2(value, old_scl.y) if axis == 0 else Vec2(old_scl.x, value)
self._on_property_changed(node, "scale", old_scl, new_scl)
# ====================================================================
# Custom properties section
# ====================================================================
def _add_settings_section(self, node: Node):
"""Discover all Property and ThemeColour descriptors and create widgets for each.
Groups properties by their ``Property.group`` attribute into separate
collapsible sections. Properties with no group go to "Properties".
For ``WorldEnvironment`` nodes all groups are merged into a single
"Post Processing" section with ``pp_`` prefixed widget keys.
"""
settings = self._collect_properties(node)
if not settings:
return
is_world_env = isinstance(node, WorldEnvironment)
# Bucket settings by group name (preserving insertion order)
grouped: OrderedDict[str, list[tuple[str, Property | ThemeColour]]] = OrderedDict()
for name, setting in settings.items():
if name in ("visible", "gizmo_colour"):
continue
group = getattr(setting, "group", "") or ""
if is_world_env:
group = "Post Processing"
elif not group:
group = "Properties"
grouped.setdefault(group, []).append((name, setting))
for section_name, entries in grouped.items():
rows: list[Control] = []
# For Control subclasses, surface the anchor-preset selector at the
# top of the "Layout" section so absolute positioning stays the
# exception and scalable UI is the discoverable default.
if section_name == "Layout" and isinstance(node, Control):
preset_widget = AnchorPresetButton(
target_control=node,
on_applied=lambda _preset, n=node: self._on_anchor_preset_applied(n),
)
rows.append(PropertyRow("Preset", preset_widget))
self._property_widgets["__anchor_preset"] = preset_widget
for name, setting in entries:
value = getattr(node, name)
if isinstance(setting, ThemeColour):
widget = self._create_colour_picker(node, name, value)
else:
widget = self._create_widget_for_property(node, name, setting, value)
if widget is not None:
widget_key = f"pp_{name}" if is_world_env else name
row = PropertyRow(name, widget)
rows.append(row)
self._property_widgets[widget_key] = widget
if rows:
self._add_section(section_name, rows)
def _on_anchor_preset_applied(self, node: Node) -> None:
"""Re-render the inspector after a preset write so anchor/margin widgets update."""
self._rebuild_inspector()
def _create_colour_picker(self, node: Node, name: str, value) -> Control | None:
"""Create a ColourPicker for a ThemeColour descriptor."""
if not (isinstance(value, tuple | list) and len(value) in (3, 4)):
return None
picker = ColourPicker()
picker.size = Vec2(200, 180)
if len(value) == 3:
picker.colour = (value[0], value[1], value[2], 1.0)
else:
picker.colour = tuple(value[:4])
picker.colour_changed.connect(
lambda colour, n=name: self._on_colour_changed(node, n, colour)
)
return picker
def _collect_properties(self, node: Node) -> dict[str, Property | ThemeColour]:
"""Walk the MRO to collect all Property and ThemeColour descriptors for the node."""
settings: dict[str, Property | ThemeColour] = {}
# Walk MRO in reverse so subclass overrides appear last
for cls in reversed(type(node).__mro__):
for attr_name, attr_val in cls.__dict__.items():
if isinstance(attr_val, Property | ThemeColour):
settings[attr_name] = attr_val
return settings
def _create_widget_for_property(
self, node: Node, name: str, setting: Property, value: Any
) -> Control | None:
"""Dispatch to the inspector widget registry."""
return build_widget(node, self, setting, name, value)
# ====================================================================
# Material property change handlers (used by InspectorContext)
# ====================================================================
def _on_material_colour_changed(self, node,
new_colour: tuple):
"""Handle material colour change with undo."""
mat = node.material
if mat is None:
return
old_colour = mat.colour
if old_colour == new_colour:
return
if self.state is not None:
cmd = PropertyCommand(
mat, "colour", old_colour, new_colour,
description=f"Change {node.name} material colour",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
else:
mat.colour = new_colour
self.property_changed.emit(node, "material.colour", old_colour, new_colour)
def _on_material_prop_changed(self, node,
prop: str, value: Any):
"""Handle material scalar property change with undo."""
mat = node.material
if mat is None:
return
old_val = getattr(mat, prop)
if old_val == value:
return
if self.state is not None:
cmd = PropertyCommand(
mat, prop, old_val, value,
description=f"Change {node.name} material.{prop}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
else:
setattr(mat, prop, value)
self.property_changed.emit(node, f"material.{prop}", old_val, value)
def _on_material_texture_changed(self, node, attr: str, path: str | None):
"""Handle material texture URI change with undo."""
mat = node.material
if mat is None:
return
old_val = getattr(mat, attr)
if old_val == path:
return
if self.state is not None:
cmd = PropertyCommand(
mat, attr, old_val, path,
description=f"Change {node.name} material.{attr}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
else:
setattr(mat, attr, path)
self.property_changed.emit(node, f"material.{attr}", old_val, path)
# ====================================================================
# Generic property change handlers
# ====================================================================
def _on_property_changed(self, node: Node, prop: str,
old_val: Any, new_val: Any):
"""Push a PropertyCommand for a simple scalar property change."""
if old_val == new_val:
return
if self.state is not None:
cmd = PropertyCommand(
node, prop, old_val, new_val,
description=f"Set {node.name}.{prop}",
)
self.state.undo_stack.push(cmd)
self.state.modified = True
else:
setattr(node, prop, new_val)
self.property_changed.emit(node, prop, old_val, new_val)
def _on_enum_changed(self, node: Node, prop: str,
setting: Property, new_idx: int):
"""Handle DropDown selection for enum settings."""
old_val = getattr(node, prop)
new_val = setting.enum[new_idx]
self._on_property_changed(node, prop, old_val, new_val)
def _on_colour_changed(self, node: Node, prop: str,
new_colour: tuple):
"""Handle colour property change with undo."""
old_colour = getattr(node, prop)
if old_colour == new_colour:
return
self._on_property_changed(node, prop, old_colour, new_colour)
def _on_vec3_changed(self, node: Node, prop: str,
axis: int, value: float):
"""Handle a single axis change on a Vec3 property."""
old = getattr(node, prop)
vals = [old.x, old.y, old.z]
vals[axis] = value
new = Vec3(vals[0], vals[1], vals[2])
("X", "Y", "Z")[axis]
self._on_property_changed(node, prop, old, new)
def _on_vec2_changed(self, node: Node, prop: str,
axis: int, value: float):
"""Handle a single axis change on a Vec2 property."""
old = getattr(node, prop)
if axis == 0:
new = Vec2(value, old.y)
else:
new = Vec2(old.x, value)
("X", "Y")[axis]
self._on_property_changed(node, prop, old, new)
def _on_tuple_changed(self, node: Node, prop: str,
axis: int, value: float, num_components: int):
"""Handle a single axis change on a tuple property."""
old = getattr(node, prop)
vals = list(old)
vals[axis] = value
new = tuple(vals)
self._on_property_changed(node, prop, old, new)
# ====================================================================
# Registry-based sections (from inspector_sections.py)
# ====================================================================
def _add_registered_sections(self, node: Node):
"""Query the section registry and add matching sections for this node."""
from .inspector_sections import InspectorContext, get_sections_for_node
# Skip registry sections whose title already exists (added by built-in code)
existing_titles = {s.header.title for s in self._sections}
ctx = InspectorContext(self)
for section in get_sections_for_node(node):
if section.section_title in existing_titles:
continue
rows = section.build_rows(node, ctx)
if rows:
self._add_section(section.section_title, rows)
existing_titles.add(section.section_title)
# ====================================================================
# Section management
# ====================================================================
def _add_section(self, title: str, rows: list[Control]):
"""Create a collapsible section with the given rows."""
header = SectionHeader(title)
header.size = Vec2(self.size.x, _section_h())
self.add_child(header)
for row in rows:
row.size = Vec2(self.size.x, row.size.y)
self.add_child(row)
section = Section(header, rows)
self._sections.append(section)
# Wire toggle
header.toggled.connect(
lambda collapsed, sec=section: self._on_section_toggled(sec, collapsed)
)
def _on_section_toggled(self, section: Section, collapsed: bool):
"""Show or hide all rows in a section."""
section.toggle(collapsed)
self._layout_dirty_ip = True
# ====================================================================
# Layout -- vertically stack all children
# ====================================================================
[docs]
def 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 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.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()
# ====================================================================
# (Utility helpers removed — widget dispatch now lives in
# inspector_widgets.default_registry())
# ====================================================================