"""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 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
# =============================================================================
# =============================================================================
# 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