Source code for simvx.editor.panels.property_widgets
"""Reusable property-editing widgets for the inspector and material editor.
Contains the widget classes used to build inspector rows:
- SectionHeader: clickable collapsible section header
- Section: logical grouping of header + body rows
- PropertyRow: label + widget pair on a single line
- VectorRow: multi-component vector editor (Vec2 or Vec3)
Also contains utility helpers for property introspection:
- is_colour_value(): heuristic test for colour tuples
- guess_step(): pick a reasonable slider step from a range
- create_widget_for_property(): factory mapping Property metadata to widgets
"""
from __future__ import annotations
from typing import Any
from simvx.core import (
CheckBox,
ColourPicker,
Control,
DropDown,
Node,
Property,
Signal,
Slider,
SpinBox,
TextEdit,
Vec2,
Vec3,
)
# ============================================================================
# Constants
# ============================================================================
_SECTION_BG = (0.18, 0.18, 0.18, 1.0)
_SECTION_HOVER_BG = (0.22, 0.22, 0.22, 1.0)
_SECTION_LABEL_COLOUR = (0.85, 0.85, 0.85, 1.0)
_SEPARATOR_COLOUR = (0.25, 0.25, 0.25, 1.0)
_LABEL_COLOUR = (0.7, 0.7, 0.7, 1.0)
from simvx.core.ui.theme import em, get_theme
def _row_h() -> float:
return em(2.18)
def _section_h() -> float:
return em(2.36)
def _label_w() -> float:
return em(7.27)
def _font_size() -> float:
return get_theme().font_size
def _padding() -> float:
return em(0.55)
def _indent() -> float:
return em(1.09)
# Axis label colours for transform rows
_AXIS_COLOURS = {
"X": (0.9, 0.3, 0.3, 1.0), # red
"Y": (0.3, 0.9, 0.3, 1.0), # green
"Z": (0.3, 0.5, 0.9, 1.0), # blue
}
# ============================================================================
# SectionHeader -- Clickable collapsible section header
# ============================================================================
[docs]
class SectionHeader(Control):
"""Clickable section header that toggles visibility of its section body."""
def __init__(self, title: str, collapsed: bool = False, **kwargs):
super().__init__(**kwargs)
self.title = title
self.collapsed = collapsed
self.toggled = Signal()
self.size = Vec2(300, _section_h())
self.mouse_filter = True
def _on_gui_input(self, event):
if event.button == 1 and event.pressed:
if self.is_point_inside(event.position):
self.collapsed = not self.collapsed
self.toggled.emit(self.collapsed)
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
bg = _SECTION_HOVER_BG if self.mouse_over else _SECTION_BG
renderer.draw_filled_rect(x, y, w, h, bg)
# Collapse arrow
scale = _font_size() / 14.0
arrow = ">" if self.collapsed else "v"
renderer.draw_text_coloured(
arrow, x + _padding(), y + (h - _font_size()) / 2,
scale, _SECTION_LABEL_COLOUR)
# Title
renderer.draw_text_coloured(
self.title, x + _padding() + 14, y + (h - _font_size()) / 2,
scale, _SECTION_LABEL_COLOUR)
# Bottom separator
renderer.draw_filled_rect(x, y + h - 1, w, 1, _SEPARATOR_COLOUR)
# ============================================================================
# Section -- A collapsible group of property rows
# ============================================================================
[docs]
class Section:
"""Logical grouping of a header and its body rows.
Not a Control itself -- just a data structure tracked by the inspector.
The header and rows are added as children of the panel's scroll content.
"""
__slots__ = ('header', 'rows', 'collapsed')
def __init__(self, header: SectionHeader, rows: list[Control]):
self.header = header
self.rows = rows
self.collapsed = header.collapsed
[docs]
def toggle(self, collapsed: bool):
self.collapsed = collapsed
self.header.collapsed = collapsed
for row in self.rows:
row.visible = not collapsed
# ============================================================================
# PropertyRow -- Label + widget pair on a single line
# ============================================================================
[docs]
class PropertyRow(Control):
"""A single property row: label on the left, widget on the right."""
def __init__(self, label_text: str, widget: Control, **kwargs):
super().__init__(**kwargs)
self.label_text = label_text
self.widget = widget
self.size = Vec2(300, _row_h())
# Widget is a child so it receives input and drawing
self.add_child(widget)
def _update_widget_layout(self):
"""Position the widget in the right portion of the row."""
_, _, w, h = self.get_rect()
widget_x = _indent() + _label_w()
widget_w = max(40, w - widget_x - _padding())
self.widget.position = Vec2(widget_x, 1)
self.widget.size = Vec2(widget_w, h - 2)
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Label
scale = _font_size() / 14.0
renderer.draw_text_coloured(
self.label_text,
x + _indent(), y + (h - _font_size()) / 2,
scale, _LABEL_COLOUR)
# ============================================================================
# VectorRow -- Multi-component vector editor (Vec2 or Vec3)
# ============================================================================
[docs]
class VectorRow(Control):
"""Edits a Vec2 or Vec3 with labeled SpinBox widgets per component."""
def __init__(self, label_text: str, components: int,
values: tuple, step: float = 0.1,
min_val: float = -10000, max_val: float = 10000,
**kwargs):
super().__init__(**kwargs)
self.label_text = label_text
self.components = components
self.size = Vec2(300, _row_h())
self.value_changed = Signal()
self._axis_labels = ("X", "Y", "Z")[:components]
self._spinboxes: list[SpinBox] = []
for i in range(components):
spin = SpinBox(
min_val=min_val, max_val=max_val,
value=float(values[i]) if i < len(values) else 0.0,
step=step,
)
spin.font_size = 11.0
self._spinboxes.append(spin)
self.add_child(spin)
[docs]
def get_values(self) -> tuple:
"""Return current component values as a tuple."""
return tuple(s.value for s in self._spinboxes)
[docs]
def set_values(self, vals: tuple):
"""Set component values without triggering signals."""
for i, s in enumerate(self._spinboxes):
if i < len(vals):
s.value = float(vals[i])
def _update_spinbox_layout(self):
_, _, w, h = self.get_rect()
available = w - _indent() - _label_w() - _padding()
comp_w = max(40, (available - (self.components - 1) * 4) / self.components)
offset_x = _indent() + _label_w()
for _i, spin in enumerate(self._spinboxes):
spin.position = Vec2(offset_x, 1)
spin.size = Vec2(comp_w, h - 2)
offset_x += comp_w + 4
[docs]
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
scale = _font_size() / 14.0
# Property label
renderer.draw_text_coloured(
self.label_text,
x + _indent(), y + (h - _font_size()) / 2,
scale, _LABEL_COLOUR)
# Axis labels drawn on top of each spinbox
available = w - _indent() - _label_w() - _padding()
comp_w = max(40, (available - (self.components - 1) * 4) / self.components)
offset_x = x + _indent() + _label_w()
label_scale = 10.0 / 14.0
for _i, axis in enumerate(self._axis_labels):
ax = offset_x + 2
ay = y + 2
colour = _AXIS_COLOURS.get(axis, _LABEL_COLOUR)
renderer.draw_text_coloured(axis, ax, ay, label_scale, colour)
offset_x += comp_w + 4
# ============================================================================
# Utility helpers
# ============================================================================
[docs]
def is_colour_value(name: str, value: Any) -> bool:
"""Heuristic: is this value a colour tuple?
Returns True if the value is a tuple of 3 or 4 floats in [0, 1]
and the property name contains a colour-related keyword.
"""
if not isinstance(value, tuple):
return False
if len(value) not in (3, 4):
return False
if not all(isinstance(v, int | float) for v in value):
return False
# Check name hints
colour_hints = ("colour", "colour", "tint", "albedo", "emissive")
name_lower = name.lower()
if any(hint in name_lower for hint in colour_hints):
return True
# Check if all values are in 0-1 range (likely a colour)
if all(0.0 <= float(v) <= 1.0 for v in value):
# 4-element tuples in 0-1 range are almost certainly colours
if len(value) == 4:
return True
return False
[docs]
def guess_step(lo: float, hi: float) -> float:
"""Pick a reasonable step value given a range."""
span = abs(hi - lo)
if span <= 1:
return 0.01
if span <= 10:
return 0.1
if span <= 100:
return 1.0
return 10.0
[docs]
def create_widget_for_property(
node: Node, name: str, prop: Property, value: Any,
*,
on_property_changed,
on_enum_changed,
on_colour_changed,
on_vec3_changed,
on_vec2_changed,
on_tuple_changed,
) -> Control | None:
"""Map a Property's type and metadata to the appropriate editor widget.
This is a factory function that creates the correct widget for a given
Property descriptor. Callback arguments are called when the widget value
changes, allowing the caller to wire up undo support.
Args:
node: The node owning the property.
name: Attribute name of the property on the node.
prop: The Property descriptor instance.
value: Current value of the property.
on_property_changed: ``(node, name, old, new) -> None``
on_enum_changed: ``(node, name, prop, new_idx) -> None``
on_colour_changed: ``(node, name, new_colour) -> None``
on_vec3_changed: ``(node, name, axis, value) -> None``
on_vec2_changed: ``(node, name, axis, value) -> None``
on_tuple_changed: ``(node, name, axis, value, num_components) -> None``
Returns:
A configured widget Control, or None if the type is unsupported.
"""
# --- Enum string -> DropDown ---
if prop.enum is not None:
idx = 0
if value in prop.enum:
idx = prop.enum.index(value)
dd = DropDown(items=list(prop.enum), selected=idx)
dd.font_size = 11.0
dd.item_selected.connect(
lambda new_idx, n=name, s=prop: on_enum_changed(node, n, s, new_idx)
)
return dd
# --- Bool -> CheckBox ---
if isinstance(value, bool):
cb = CheckBox("", checked=value)
cb.toggled.connect(
lambda checked, n=name: on_property_changed(node, n, not checked, checked)
)
return cb
# --- Colour tuple (3 or 4 floats in 0-1) -> ColourPicker ---
if is_colour_value(name, value):
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: on_colour_changed(node, n, colour)
)
return picker
# --- Vec3 -> 3x SpinBox ---
if isinstance(value, Vec3):
row = VectorRow("", 3, (value.x, value.y, value.z), step=0.1)
for i, spin in enumerate(row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, n=name, ax=axis: on_vec3_changed(node, n, ax, val)
)
return row
# --- Vec2 -> 2x SpinBox ---
if isinstance(value, Vec2):
row = VectorRow("", 2, (value.x, value.y), step=0.1)
for i, spin in enumerate(row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, n=name, ax=axis: on_vec2_changed(node, n, ax, val)
)
return row
# --- Float with range -> Slider ---
if isinstance(value, float) and prop.range is not None:
lo, hi = prop.range
slider = Slider(min_val=lo, max_val=hi, value=value)
slider.step = guess_step(lo, hi)
slider.value_changed.connect(
lambda val, n=name: on_property_changed(node, n, getattr(node, n), val)
)
return slider
# --- Float / int without range -> SpinBox ---
if isinstance(value, int | float):
lo = prop.range[0] if prop.range else -10000
hi = prop.range[1] if prop.range else 10000
step = 1.0 if isinstance(value, int) else 0.1
_is_int = isinstance(value, int)
spin = SpinBox(min_val=lo, max_val=hi, value=float(value), step=step)
spin.font_size = 11.0
spin.value_changed.connect(
lambda val, n=name, is_int=_is_int:
on_property_changed(
node, n, getattr(node, n),
int(val) if is_int else val)
)
return spin
# --- String -> TextEdit ---
if isinstance(value, str):
edit = TextEdit(text=value, placeholder=prop.hint or name)
edit.font_size = 11.0
edit.text_submitted.connect(
lambda text, n=name: on_property_changed(node, n, getattr(node, n), text)
)
return edit
# --- Tuple that looks like a vector ---
if isinstance(value, tuple) and len(value) in (2, 3) and all(
isinstance(v, int | float) for v in value
):
if not is_colour_value(name, value):
comps = len(value)
row = VectorRow("", comps, tuple(float(v) for v in value), step=0.1)
for i, spin in enumerate(row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, n=name, ax=axis, nc=comps:
on_tuple_changed(node, n, ax, val, nc)
)
return row
return None