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/Vec3/Vec4, int or float)
- ``ResourcePicker`` -- File path display with Browse / Clear buttons
- ``BitToggle`` -- Small on/off toggle square showing a bit index
- ``BitmaskRow`` -- 8, 16, 24, 32-bit grid of ``BitToggle`` for a bitmask int
- ``QuatEulerRow`` -- 3 degree-SpinBoxes backed by a Quat
- ``NodePathPicker`` -- Shows a scene-relative path with a "..." picker popup
"""

import math
from typing import TYPE_CHECKING, Any

from simvx.core import (
    Button,
    Control,
    FileDialog,
    HBoxContainer,
    Label,
    Quat,
    Signal,
    SpinBox,
    TreeItem,
    TreeView,
    Vec2,
)
from simvx.core.ui.theme import em, get_theme

if TYPE_CHECKING:
    from simvx.core import Node

# -- 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(): return em(2.36)
[docs] def padding(): return em(0.55)
[docs] def font_size(): return get_theme().font_size
[docs] def row_height(): return em(2.18)
[docs] def label_width(): return em(7.27)
[docs] def indent(): 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_rect((x, y), (w, h), colour=bg, filled=True) 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(arrow, (x + pad, y + (h - fs) / 2), colour=text_colour, scale=scale) renderer.draw_text(self.title, (x + pad + 14, y + (h - fs) / 2), colour=text_colour, scale=scale) border = getattr(t, "border", self._separator_colour) renderer.draw_rect((x, y + h - 1), (w, 1), colour=border, filled=True)
# ============================================================================= # 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. The row auto-sizes in height to at least ``min_height`` (falls back to :func:`row_height`) and grows to fit taller widgets like multi-line editors or bitmask grids. """ def __init__(self, label_text: str, widget: Control, *, min_height: float | None = None, **kwargs): super().__init__(**kwargs) self.label_text = label_text self.widget = widget base_h = min_height if min_height is not None else row_height() row_h = max(base_h, widget.size.y + 2) 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_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( self.label_text, (x + indent(), y + (h - font_size()) / 2), colour=get_theme().text_label, scale=scale)
# ============================================================================= # VectorRow -- Multi-component vector editor (Vec2/Vec3/Vec4; int or float) # =============================================================================
[docs] class VectorRow(Control): """Edits a Vec2/Vec3/Vec4 with labeled SpinBox widgets per component. When ``is_int=True`` the SpinBoxes step by 1 and their reported values are cast to ``int``. """ _AXIS_LABELS = ("X", "Y", "Z", "W") def __init__(self, label_text: str, components: int, values: tuple, step: float = 0.1, min_val: float = -10000, max_val: float = 10000, *, is_int: bool = False, **kwargs): if components not in (2, 3, 4): raise ValueError(f"VectorRow supports 2, 3 or 4 components, got {components}") super().__init__(**kwargs) self.label_text = label_text self.components = components self.is_int = is_int self.size = Vec2(300, row_height()) self.value_changed = Signal() self._axis_labels = self._AXIS_LABELS[:components] self._spinboxes: list[SpinBox] = [] actual_step = 1.0 if is_int else step for i in range(components): raw = values[i] if i < len(values) else 0 spin = SpinBox( min_val=min_val, max_val=max_val, value=float(raw), step=actual_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 (int or float).""" if self.is_int: return tuple(int(s.value) for s in self._spinboxes) 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 spin in 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( self.label_text, (x + indent(), y + (h - font_size()) / 2), colour=t.text_label, scale=scale) # Axis labels drawn on top of each spinbox axis_colours = {"X": t.gizmo_x, "Y": t.gizmo_y, "Z": t.gizmo_z, "W": t.text_label} 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 axis in self._axis_labels: ax = offset_x + 2 ay = y + 2 colour = axis_colours.get(axis, t.text_label) renderer.draw_text(axis, (ax, ay), colour=colour, scale=label_scale) 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()
# ============================================================================= # BitToggle -- Small toggle button showing a bit number # =============================================================================
[docs] class BitToggle(Control): """22x20 toggle button showing a bit index (1-based label). Emits ``toggled(bool)`` on click. Highlighted when ``active`` is True. """ _ACTIVE_BG = (0.25, 0.45, 0.85, 1.0) _INACTIVE_BG = (0.18, 0.18, 0.18, 1.0) _HOVER_BG = (0.3, 0.3, 0.3, 1.0) _ACTIVE_HOVER_BG = (0.35, 0.55, 0.95, 1.0) _TEXT_COLOUR = (0.9, 0.9, 0.9, 1.0) _BORDER_COLOUR = (0.4, 0.4, 0.4, 1.0) def __init__(self, bit_number: int, active: bool = False, *, tooltip: str | None = None, **kwargs): super().__init__(**kwargs) self.bit_number = bit_number self.active = active self.toggled = Signal() self.size = Vec2(22, 20) self.mouse_filter = True if tooltip: self.tooltip = tooltip def _on_gui_input(self, event): if event.button == 1 and event.pressed: if self.is_point_inside(event.position): self.active = not self.active self.toggled.emit(self.active)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() if self.active: bg = self._ACTIVE_HOVER_BG if self.mouse_over else self._ACTIVE_BG else: bg = self._HOVER_BG if self.mouse_over else self._INACTIVE_BG renderer.draw_rect((x, y), (w, h), colour=bg, filled=True) renderer.draw_rect((x, y), (w, h), colour=self._BORDER_COLOUR) label = str(self.bit_number + 1) scale = 9.0 / 14.0 char_w = 9.0 * 0.6 text_w = len(label) * char_w tx = x + (w - text_w) / 2 ty = y + (h - 9.0) / 2 renderer.draw_text(label, (tx, ty), colour=self._TEXT_COLOUR, scale=scale)
# ============================================================================= # BitmaskRow -- 8/16/24/32-bit grid of BitToggle, one row per byte # =============================================================================
[docs] class BitmaskRow(Control): """Grid of ``bits`` toggles representing an integer bitmask. Laid out as ``bits // 8`` rows of 8 toggles. Optional per-bit ``names`` list applies tooltips. """ _TOGGLE_W = 22.0 _TOGGLE_H = 20.0 _GAP = 2.0 def __init__(self, label: str, value: int, *, bits: int = 32, names: list[str] | None = None, **kwargs): if bits <= 0 or bits % 8 != 0: raise ValueError(f"BitmaskRow bits must be a positive multiple of 8, got {bits}") super().__init__(**kwargs) self.label = label self.bits = bits self.value = int(value) self.value_changed = Signal() num_rows = bits // 8 row_h = self._TOGGLE_H total_h = num_rows * row_h + max(0, (num_rows - 1)) * self._GAP self.size = Vec2(300, total_h) self._toggles: list[BitToggle] = [] for bit in range(bits): tip = names[bit] if names else None t = BitToggle(bit, active=bool(self.value & (1 << bit)), tooltip=tip) t.toggled.connect(lambda active, b=bit: self._on_bit_toggled(b, active)) self._toggles.append(t) self.add_child(t)
[docs] def set_value(self, value: int): """Set the bitmask value and sync each toggle.""" self.value = int(value) for bit, toggle in enumerate(self._toggles): toggle.active = bool(self.value & (1 << bit))
def _on_bit_toggled(self, bit: int, active: bool): if active: new_val = self.value | (1 << bit) else: new_val = self.value & ~(1 << bit) if new_val != self.value: self.value = new_val self.value_changed.emit(new_val) def _layout_toggles(self): tw = self._TOGGLE_W th = self._TOGGLE_H gap = self._GAP # Layout within row, starting after the indent/label region origin_x = indent() + label_width() for i, toggle in enumerate(self._toggles): byte = i // 8 col = i % 8 toggle.position = Vec2(origin_x + col * (tw + gap), byte * (th + gap)) toggle.size = Vec2(tw, th)
[docs] def process(self, dt: float): self._layout_toggles()
[docs] def draw(self, renderer): t = get_theme() x, y, _, _ = self.get_global_rect() scale = font_size() / 14.0 renderer.draw_text( self.label, (x + indent(), y + (self._TOGGLE_H - font_size()) / 2), colour=t.text_label, scale=scale)
# ============================================================================= # QuatEulerRow -- 3 SpinBoxes (degrees) backed by a Quat # =============================================================================
[docs] class QuatEulerRow(Control): """Edits a Quat as pitch/yaw/roll degrees via three SpinBoxes. Emits ``value_changed(Quat)`` whenever any axis changes. """ def __init__(self, label: str, quat: Quat | None = None, **kwargs): super().__init__(**kwargs) self.label = label self._quat = Quat(quat) if quat is not None else Quat() self.value_changed = Signal() self.size = Vec2(300, row_height()) pitch, yaw, roll = self._quat.euler_angles() values = (math.degrees(pitch), math.degrees(yaw), math.degrees(roll)) self._spinboxes: list[SpinBox] = [] for deg in values: spin = SpinBox(min_val=-360, max_val=360, value=float(deg), step=1.0) spin.font_size = 11.0 spin.value_changed.connect(lambda _v: self._on_axis_changed()) self._spinboxes.append(spin) self.add_child(spin) def _on_axis_changed(self): p = math.radians(self._spinboxes[0].value) y = math.radians(self._spinboxes[1].value) r = math.radians(self._spinboxes[2].value) self._quat = Quat.from_euler(p, y, r) self.value_changed.emit(self._quat)
[docs] def set_quat(self, quat: Quat): """Set the underlying Quat and refresh the degree spinboxes.""" self._quat = Quat(quat) pitch, yaw, roll = self._quat.euler_angles() self._spinboxes[0].value = math.degrees(pitch) self._spinboxes[1].value = math.degrees(yaw) self._spinboxes[2].value = math.degrees(roll)
[docs] def get_quat(self) -> Quat: return Quat(self._quat)
def _update_layout(self): _, _, w, h = self.get_rect() available = w - indent() - label_width() - padding() comp_w = max(40, (available - 2 * 4) / 3) offset_x = indent() + label_width() for spin in 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_layout()
[docs] def draw(self, renderer): t = get_theme() x, y, w, h = self.get_global_rect() scale = font_size() / 14.0 renderer.draw_text( self.label, (x + indent(), y + (h - font_size()) / 2), colour=t.text_label, scale=scale) # Axis labels axis_colours = (t.gizmo_x, t.gizmo_y, t.gizmo_z) available = w - indent() - label_width() - padding() comp_w = max(40, (available - 2 * 4) / 3) offset_x = x + indent() + label_width() label_scale = 10.0 / 14.0 for i, axis in enumerate(("X", "Y", "Z")): renderer.draw_text(axis, (offset_x + 2, y + 2), colour=axis_colours[i], scale=label_scale) offset_x += comp_w + 4
# ============================================================================= # NodePathPicker -- path display with popup scene-tree picker # ============================================================================= class _ScenePickerPopup(Control): """Modal popup hosting a TreeView of the scene for NodePath picking.""" def __init__(self, root_node: Node | None, type_filter: type | None, on_select, **kwargs): super().__init__(**kwargs) self.bg_colour = (0.1, 0.1, 0.12, 1.0) self._type_filter = type_filter self._on_select = on_select self.size = Vec2(260, 340) self._node_for_item: dict[int, Any] = {} # id(TreeItem) -> Node self._tree_view = TreeView() self._tree_view.size = Vec2(260, 300) self._tree_view.item_selected.connect(self._on_item_selected) self.add_child(self._tree_view) cancel_btn = Button("Cancel") cancel_btn.size = Vec2(80, 28) cancel_btn.pressed.connect(self._on_cancel) self.add_child(cancel_btn) self._cancel_btn = cancel_btn if root_node is not None: root_item = self._build_tree_item(root_node) self._tree_view.root = root_item def _build_tree_item(self, node) -> TreeItem: item = TreeItem(node.name or type(node).__name__, data=node) self._node_for_item[id(item)] = node for child in getattr(node, "children", []) or []: item.add_child(self._build_tree_item(child)) return item def _is_allowed(self, node) -> bool: if self._type_filter is None: return True return isinstance(node, self._type_filter) def _on_item_selected(self, item: TreeItem): node = self._node_for_item.get(id(item)) if node is None: return if not self._is_allowed(node): return self._on_select(node) def _on_cancel(self): self._on_select(None) def _update_layout(self): _, _, w, h = self.get_rect() self._tree_view.position = Vec2(4, 4) self._tree_view.size = Vec2(w - 8, h - 40) self._cancel_btn.position = Vec2(w - 84, h - 32) def process(self, dt: float): self._update_layout() def draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) renderer.draw_rect((x, y), (w, h), colour=(0.35, 0.45, 0.6, 1.0))
[docs] class NodePathPicker(HBoxContainer): """Displays a scene-relative node path with a picker and clear button. Clicking ``...`` opens a tree-view popup of the scene, rooted at ``scene_root`` (provided via :meth:`set_scene_root`). ``type_filter`` restricts valid selections. Emits ``path_selected(str)`` / ``cleared()``. """ def __init__(self, current: str = "", *, type_filter: type | None = None, **kwargs): super().__init__(**kwargs) self.separation = 3.0 self.size = Vec2(300, row_height()) self.path_selected = Signal() self.cleared = Signal() self._type_filter = type_filter self._scene_root: Any = None self._source_node: Any = None self._popup: _ScenePickerPopup | None = None self._label = Label(current or "None") self._label.font_size = 11.0 self._label.text_colour = (0.7, 0.8, 0.9, 1.0) if current else (0.5, 0.5, 0.5, 1.0) self._label.size = Vec2(140, row_height()) self.add_child(self._label) pick_btn = Button("...") pick_btn.size = Vec2(28, row_height()) pick_btn.font_size = 10.0 pick_btn.pressed.connect(self._open_picker) self.add_child(pick_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): """Update the displayed path text.""" self._label.text = path or "None" self._label.text_colour = (0.7, 0.8, 0.9, 1.0) if path else (0.5, 0.5, 0.5, 1.0)
[docs] def set_scene(self, scene_root: Any, source_node: Any): """Configure the picker with the tree root and the node the path is relative to.""" self._scene_root = scene_root self._source_node = source_node
def _on_clear(self): self.set_path("") self.cleared.emit() def _open_picker(self): if self._scene_root is None: return popup = _ScenePickerPopup(self._scene_root, self._type_filter, self._on_picked) if self._tree is not None and self._tree.root is not None: self._tree.root.add_child(popup) self._popup = popup def _on_picked(self, node): if self._popup is not None and self._popup.parent is not None: self._popup.parent.remove_child(self._popup) self._popup = None if node is None: return path = self._compute_relative_path(node) self.set_path(path) self.path_selected.emit(path) def _compute_relative_path(self, target) -> str: """Return a scene-tree-relative path from ``source_node`` to ``target``.""" source = self._source_node if source is None: return target.name or "" # Collect ancestor chain for source and target from scene_root src_chain = _ancestor_chain(source, self._scene_root) dst_chain = _ancestor_chain(target, self._scene_root) # Strip common prefix common = 0 while common < len(src_chain) and common < len(dst_chain) and src_chain[common] is dst_chain[common]: common += 1 up_count = max(0, len(src_chain) - common - 1) down = dst_chain[common:] parts = [".."] * up_count + [n.name or type(n).__name__ for n in down] return "/".join(parts) if parts else ""
def _ancestor_chain(node, root) -> list: """Return [root, ..., node] if root is an ancestor of node; else [node].""" if node is None: return [] chain = [] cur = node while cur is not None: chain.append(cur) if cur is root: break cur = getattr(cur, "parent", None) chain.reverse() return chain