Source code for simvx.editor.panels.section_widgets

"""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 SECTION_HEADER_HEIGHT(): # noqa: N802 -- kept uppercase for backward compat return em(2.36)
[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 # =============================================================================
[docs] class SectionHeader(Control): """Clickable section header that toggles visibility of its section body.""" def __init__( self, title: str, collapsed: bool = False, *, label_colour: tuple[float, ...] = SECTION_LABEL_COLOUR, bg_colour: tuple[float, ...] = SECTION_BG, hover_bg_colour: tuple[float, ...] = SECTION_HOVER_BG, separator_colour: tuple[float, ...] = SEPARATOR_COLOUR, **kwargs, ): super().__init__(**kwargs) self.title = title self.collapsed = collapsed self.toggled = Signal() self.size = Vec2(300, SECTION_HEADER_HEIGHT()) self.mouse_filter = True # Per-instance colour overrides self._label_colour = label_colour self._bg_colour = bg_colour self._hover_bg_colour = hover_bg_colour self._separator_colour = separator_colour 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): t = get_theme() x, y, w, h = self.get_global_rect() bg = getattr(t, "hover_bg", self._hover_bg_colour) if self.mouse_over else getattr(t, "section_bg", self._bg_colour) renderer.draw_filled_rect(x, y, w, h, bg) fs = FONT_SIZE() scale = fs / 14.0 pad = PADDING() text_colour = getattr(t, "text", self._label_colour) arrow = ">" if self.collapsed else "v" renderer.draw_text_coloured(arrow, x + pad, y + (h - fs) / 2, scale, text_colour) renderer.draw_text_coloured(self.title, x + pad + 14, y + (h - fs) / 2, scale, text_colour) border = getattr(t, "border", self._separator_colour) renderer.draw_filled_rect(x, y + h - 1, w, 1, border)
# ============================================================================= # 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()