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 process(self, dt: float): self._update_widget_layout()
[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 process(self, dt: float): self._update_spinbox_layout()
[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