"""Shared section and property-row widgets for editor panels.
Provides the primitive UI building blocks used by the inspector, material
editor, and other panels that display collapsible property sections:
- ``SectionHeader`` -- Clickable toggle bar for collapsible sections
- ``Section`` -- Lightweight data-structure grouping header + body rows
- ``PropertyRow`` -- Label + widget pair on a single line
- ``VectorRow`` -- Multi-component vector editor (Vec2 or Vec3)
- ``ResourcePicker`` -- File path display with Browse / Clear buttons
"""
from __future__ import annotations
from simvx.core import (
Button,
Control,
FileDialog,
HBoxContainer,
Label,
Signal,
SpinBox,
Vec2,
)
from simvx.core.ui.theme import em, get_theme
# -- Default colours (matching the editor's dark theme) -----------------------
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)
[docs]
def PADDING(): # noqa: N802
return em(0.55)
[docs]
def FONT_SIZE(): # noqa: N802
return get_theme().font_size
[docs]
def ROW_HEIGHT(): # noqa: N802
return em(2.18)
[docs]
def LABEL_WIDTH(): # noqa: N802
return em(7.27)
[docs]
def INDENT(): # noqa: N802
return em(1.09)
# =============================================================================
# SectionHeader -- Clickable collapsible section header
# =============================================================================
# =============================================================================
# Section -- Collapsible group of rows
# =============================================================================
[docs]
class Section:
"""Logical grouping of a header and its body rows.
Not a Control itself -- just a data structure tracked by the owning panel.
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_HEIGHT())
# 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_WIDTH()
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, get_theme().text_label)
# =============================================================================
# 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_HEIGHT())
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_WIDTH() - PADDING()
comp_w = max(40, (available - (self.components - 1) * 4) / self.components)
offset_x = INDENT() + LABEL_WIDTH()
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):
t = get_theme()
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, t.text_label)
# Axis labels drawn on top of each spinbox
axis_colours = {"X": t.gizmo_x, "Y": t.gizmo_y, "Z": t.gizmo_z}
available = w - INDENT() - LABEL_WIDTH() - PADDING()
comp_w = max(40, (available - (self.components - 1) * 4) / self.components)
offset_x = x + INDENT() + LABEL_WIDTH()
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, t.text_label)
renderer.draw_text_coloured(axis, ax, ay, label_scale, colour)
offset_x += comp_w + 4
# =============================================================================
# ResourcePicker -- File path display with Browse / Clear buttons
# =============================================================================
[docs]
class ResourcePicker(HBoxContainer):
"""Displays a file path with Browse and Clear buttons.
Emits ``file_selected(path)`` when the user picks a file,
and ``cleared()`` when the Clear button is pressed.
"""
def __init__(self, current_path: str | None = None, file_filter: str = "*.*", **kwargs):
super().__init__(**kwargs)
self.separation = 3.0
self.size = Vec2(300, ROW_HEIGHT())
self.file_selected = Signal()
self.cleared = Signal()
self._file_filter = file_filter
self._file_dialog: FileDialog | None = None
self._path_label = Label(current_path or "None")
self._path_label.font_size = 11.0
self._path_label.text_colour = (0.7, 0.8, 0.9, 1.0) if current_path else (0.5, 0.5, 0.5, 1.0)
self._path_label.size = Vec2(140, ROW_HEIGHT())
self.add_child(self._path_label)
browse_btn = Button("Browse...")
browse_btn.size = Vec2(60, ROW_HEIGHT())
browse_btn.font_size = 10.0
browse_btn.pressed.connect(self._on_browse)
self.add_child(browse_btn)
clear_btn = Button("X")
clear_btn.size = Vec2(22, ROW_HEIGHT())
clear_btn.font_size = 10.0
clear_btn.pressed.connect(self._on_clear)
self.add_child(clear_btn)
[docs]
def set_path(self, path: str | None):
"""Update the displayed path."""
self._path_label.text = path or "None"
self._path_label.text_colour = (0.7, 0.8, 0.9, 1.0) if path else (0.5, 0.5, 0.5, 1.0)
def _on_browse(self):
if self._file_dialog is None:
self._file_dialog = FileDialog()
self._file_dialog.file_selected.connect(self._on_file_chosen)
if self._tree:
self._tree.root.add_child(self._file_dialog)
self._file_dialog.show(mode="open", filter=self._file_filter)
def _on_file_chosen(self, path: str):
self.set_path(path)
self.file_selected.emit(path)
def _on_clear(self):
self.set_path(None)
self.cleared.emit()