"""Inspector widget registry and built-in factories.
The inspector maps each property to an editor widget via a priority-ordered
registry of :class:`WidgetFactory` objects. Built-in factories dispatch on
(a) typed :class:`~simvx.core.Property` subclasses -- :class:`Colour`,
:class:`FilePath`, :class:`Multiline`, :class:`Bitmask`, :class:`NodePath` --
and (b) value types for plain :class:`Property` declarations (bool, int,
float, Vec2/3/4, Quat, tuple, str, enum).
External callers compose a :class:`WidgetContext` and call
:func:`build_widget`, which scans the registry in descending priority order
and returns the first factory's widget or ``None``.
"""
import math
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Protocol
from simvx.core import (
Bitmask,
Button,
CheckBox,
Colour,
ColourPicker,
Control,
DropDown,
FilePath,
Label,
Multiline,
Node,
NodePath,
Property,
PropertyCommand,
Quat,
Signal,
SpinBox,
TextEdit,
Vec2,
Vec3,
)
from simvx.core.ui.code_edit import CodeTextEdit
from simvx.core.ui.multiline import MultiLineTextEdit
from .section_widgets import (
BitmaskRow,
NodePathPicker,
QuatEulerRow,
ResourcePicker,
SectionHeader,
VectorRow,
)
if TYPE_CHECKING:
from .properties import PropertiesPanel
# =============================================================================
# Context -- the data a factory needs to build a widget
# =============================================================================
[docs]
@dataclass
class WidgetContext:
"""Bundle of data passed to widget factories.
Attributes:
node: The node whose property is being edited.
inspector: The owning :class:`PropertiesPanel` (used for scene access
and undo/redo via ``inspector.state``).
setting: The ``Property`` descriptor (may be a typed subclass).
name: Attribute name of the property on ``node``.
value: Current value (``getattr(node, name)``).
"""
node: Node
inspector: PropertiesPanel
setting: Property
name: str
value: Any
[docs]
def push_change(self, old: Any, new: Any) -> None:
"""Push an undo-aware property change."""
if old == new:
return
state = getattr(self.inspector, "state", None)
if state is not None:
cmd = PropertyCommand(
self.node, self.name, old, new,
description=f"Set {self.node.name}.{self.name}",
)
state.undo_stack.push(cmd)
state.modified = True
else:
setattr(self.node, self.name, new)
emit = getattr(self.inspector, "property_changed", None)
if emit is not None:
emit.emit(self.node, self.name, old, new)
# =============================================================================
# Factory protocol + registry
# =============================================================================
@dataclass
class _Entry:
factory: WidgetFactory
priority: int
# =============================================================================
# Simple callable-based factory helper
# =============================================================================
class _FnFactory:
"""Factory built from a ``matches`` predicate and a ``build`` callable."""
__slots__ = ("_matches", "_build")
def __init__(self, matches: Callable[[WidgetContext], bool], build: Callable[[WidgetContext], Control | None]):
self._matches = matches
self._build = build
def matches(self, ctx: WidgetContext) -> bool:
return self._matches(ctx)
def build(self, ctx: WidgetContext) -> Control | None:
return self._build(ctx)
# =============================================================================
# Built-in factory builders
# =============================================================================
def _enum_label(value: Any) -> str:
"""Render an enum entry as its ``.name`` if available, else ``str(value)``."""
if isinstance(value, Enum):
return value.name
return str(value)
def _guess_step(lo: float, hi: float) -> float:
"""Pick a reasonable step for a slider given ``(lo, hi)`` 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
# ----- Typed Property subclass factories -------------------------------------
def _build_colour(ctx: WidgetContext) -> Control:
setting: Colour = ctx.setting # type: ignore[assignment]
picker = ColourPicker()
picker.size = Vec2(200, 180 if setting.has_alpha else 150)
picker.has_alpha = setting.has_alpha
if isinstance(ctx.value, tuple | list):
if len(ctx.value) == 3:
picker.colour = (ctx.value[0], ctx.value[1], ctx.value[2], 1.0)
else:
picker.colour = tuple(ctx.value[:4])
picker.colour_changed.connect(
lambda colour, c=ctx, has_alpha=setting.has_alpha: c.push_change(
getattr(c.node, c.name),
tuple(colour[:3]) if not has_alpha else tuple(colour),
)
)
return picker
def _build_file_path(ctx: WidgetContext) -> Control:
setting: FilePath = ctx.setting # type: ignore[assignment]
picker = ResourcePicker(current_path=ctx.value or None, file_filter=setting.filter)
picker.file_selected.connect(
lambda path, c=ctx: c.push_change(getattr(c.node, c.name), path)
)
picker.cleared.connect(
lambda c=ctx: c.push_change(getattr(c.node, c.name), "")
)
return picker
def _build_multiline(ctx: WidgetContext) -> Control:
setting: Multiline = ctx.setting # type: ignore[assignment]
if setting.syntax == "python":
edit = CodeTextEdit()
else:
edit = MultiLineTextEdit()
edit.text = ctx.value or ""
line_h = 18.0
edit.size = Vec2(200, line_h * setting.min_lines + 8)
edit.text_changed.connect(
lambda text, c=ctx: c.push_change(getattr(c.node, c.name), text)
)
return edit
def _build_bitmask(ctx: WidgetContext) -> Control:
setting: Bitmask = ctx.setting # type: ignore[assignment]
row = BitmaskRow("", int(ctx.value), bits=setting.bits, names=setting.names)
row.value_changed.connect(
lambda new_val, c=ctx: c.push_change(int(getattr(c.node, c.name)), int(new_val))
)
return row
def _build_node_path(ctx: WidgetContext) -> Control:
setting: NodePath = ctx.setting # type: ignore[assignment]
picker = NodePathPicker(ctx.value or "", type_filter=setting.type_filter)
state = getattr(ctx.inspector, "state", None)
scene = getattr(state, "edited_scene", None) if state else None
root = getattr(scene, "root", None) if scene is not None else None
picker.set_scene(root, ctx.node)
picker.path_selected.connect(
lambda path, c=ctx: c.push_change(getattr(c.node, c.name), path)
)
picker.cleared.connect(
lambda c=ctx: c.push_change(getattr(c.node, c.name), "")
)
return picker
# ----- Enum factory ----------------------------------------------------------
def _build_enum(ctx: WidgetContext) -> Control:
setting = ctx.setting
items = [_enum_label(x) for x in setting.enum] # type: ignore[arg-type]
try:
idx = list(setting.enum).index(ctx.value) if setting.enum else 0 # type: ignore[arg-type]
except ValueError:
idx = 0
dd = DropDown(items=items, selected=idx)
dd.font_size = 11.0
dd.item_selected.connect(
lambda new_idx, c=ctx, s=setting: c.push_change(
getattr(c.node, c.name), s.enum[new_idx]
)
)
return dd
# ----- Primitive factories ---------------------------------------------------
def _build_bool(ctx: WidgetContext) -> Control:
cb = CheckBox("", checked=bool(ctx.value))
cb.toggled.connect(
lambda checked, c=ctx: c.push_change(not checked, checked)
)
return cb
def _build_quat(ctx: WidgetContext) -> Control:
row = QuatEulerRow("", ctx.value)
row.value_changed.connect(
lambda new_q, c=ctx: c.push_change(Quat(getattr(c.node, c.name)), new_q)
)
return row
def _build_vec(ctx: WidgetContext) -> Control | None:
v = ctx.value
if isinstance(v, Vec3):
comps = 3
values = (v.x, v.y, v.z)
elif isinstance(v, Vec2):
comps = 2
values = (v.x, v.y)
else:
return None
row = VectorRow("", comps, values, step=0.1)
for i, spin in enumerate(row._spinboxes):
spin.value_changed.connect(
lambda val, c=ctx, ax=i, n=comps: _on_vec_axis(c, ax, val, n)
)
return row
def _on_vec_axis(ctx: WidgetContext, axis: int, value: float, components: int):
old = getattr(ctx.node, ctx.name)
if components == 3:
vals = [old.x, old.y, old.z]
vals[axis] = value
new = Vec3(vals[0], vals[1], vals[2])
else:
vals = [old.x, old.y]
vals[axis] = value
new = Vec2(vals[0], vals[1])
ctx.push_change(old, new)
def _build_ranged_number(ctx: WidgetContext) -> Control:
lo, hi = ctx.setting.range # type: ignore[misc]
is_int = isinstance(ctx.value, int) and not isinstance(ctx.value, bool)
step = 1.0 if is_int else _guess_step(lo, hi)
spin = SpinBox(min_val=lo, max_val=hi, value=float(ctx.value), step=step)
spin.font_size = 11.0
spin.value_changed.connect(
lambda val, c=ctx, ii=is_int: c.push_change(
getattr(c.node, c.name),
int(val) if ii else val,
)
)
return spin
def _build_unranged_number(ctx: WidgetContext) -> Control:
is_int = isinstance(ctx.value, int) and not isinstance(ctx.value, bool)
lo = ctx.setting.range[0] if ctx.setting.range else -10000
hi = ctx.setting.range[1] if ctx.setting.range else 10000
step = 1.0 if is_int else 0.1
spin = SpinBox(min_val=lo, max_val=hi, value=float(ctx.value), step=step)
spin.font_size = 11.0
spin.value_changed.connect(
lambda val, c=ctx, ii=is_int: c.push_change(
getattr(c.node, c.name),
int(val) if ii else val,
)
)
return spin
def _build_int_tuple(ctx: WidgetContext) -> Control:
v = tuple(ctx.value)
row = VectorRow("", len(v), tuple(float(x) for x in v), is_int=True)
for i, spin in enumerate(row._spinboxes):
spin.value_changed.connect(
lambda val, c=ctx, ax=i, n=len(v): _on_tuple_axis(c, ax, int(val), n)
)
return row
def _build_float_tuple(ctx: WidgetContext) -> Control:
v = tuple(float(x) for x in ctx.value)
row = VectorRow("", len(v), v, step=0.1)
for i, spin in enumerate(row._spinboxes):
spin.value_changed.connect(
lambda val, c=ctx, ax=i, n=len(v): _on_tuple_axis(c, ax, float(val), n)
)
return row
def _on_tuple_axis(ctx: WidgetContext, axis: int, value, num_components: int):
old = getattr(ctx.node, ctx.name)
vals = list(old)
vals[axis] = value
ctx.push_change(old, tuple(vals))
def _build_text(ctx: WidgetContext) -> Control:
setting = ctx.setting
edit = TextEdit(text=ctx.value or "", placeholder=setting.hint or ctx.name)
edit.font_size = 11.0
edit.text_submitted.connect(
lambda text, c=ctx: c.push_change(getattr(c.node, c.name), text)
)
return edit
# =============================================================================
# Collection editors -- shared base for list / dict per-element row widgets
# =============================================================================
_PRIMITIVE_DEFAULTS: tuple[tuple[type, Any], ...] = (
(bool, False),
(int, 0),
(float, 0.0),
(str, ""),
)
def _default_for_sample(sample: Any) -> Any:
"""Pick a sensible default value of the same type as ``sample``.
Used by both list and dict editors when adding a new entry. Falls back to
an empty string when the sample type can't be default-constructed.
"""
if sample is None:
return ""
for typ, default in _PRIMITIVE_DEFAULTS:
if type(sample) is typ:
return default
try:
return type(sample)()
except TypeError:
return None
def _default_for_list(values: list) -> Any:
"""Default value for a new list element, inferred from the first item."""
return _default_for_sample(values[0]) if values else ""
def _default_for_dict(values: dict) -> Any:
"""Default value for a new dict entry, inferred from any existing value."""
if not values:
return ""
# ``next(iter(...))`` works for both Python 3.7+ insertion-ordered dicts
# and any other mapping type; we just need one sample.
sample_key = next(iter(values))
return _default_for_sample(values[sample_key])
class _ElementProxy:
"""Tiny attribute-bag used as the ``node`` for an element widget.
Exposes ``value`` (the element) so registry factories that read or write
``getattr(node, name)`` see the current element and writes go through
:meth:`__setattr__` -> :meth:`_CollectionEditor._set_element`.
"""
__slots__ = ("_owner", "_key")
def __init__(self, owner: _CollectionEditor, key: Any):
object.__setattr__(self, "_owner", owner)
object.__setattr__(self, "_key", key)
def __getattr__(self, name: str) -> Any:
if name == "value":
return self._owner._get_element(self._key)
raise AttributeError(name)
def __setattr__(self, name: str, value: Any) -> None:
if name == "value":
self._owner._set_element(self._key, value)
return
object.__setattr__(self, name, value)
class _CollectionEditor(Control):
"""Shared header + rows + add/remove plumbing for list and dict editors.
Subclasses provide the collection-specific behaviour:
* :meth:`_iter_keys` -- iteration order over the current entries
* :meth:`_get_element` / :meth:`_set_element` -- element accessors keyed by
whatever the subclass uses (int index, str key, ...)
* :meth:`_remove_key` -- remove a single entry
* :meth:`_row_label` -- text shown in the leftmost cell of each row
* :meth:`_clone_collection` -- snapshot the current collection (for undo)
* :meth:`_write_back_value` -- value to assign back to ``node.attr``
* :meth:`_on_add` -- bound to the ``+`` button (subclass UX varies)
The base owns the header, the row container, the add button, layout,
redraw, and the mutation write-back path.
"""
_ROW_HEIGHT = 24.0
_BTN_W = 22.0
_LABEL_W = 64.0
_PAD = 4.0
_ADD_BTN_TEXT = "+ Add"
def __init__(self, ctx: WidgetContext):
super().__init__()
self._ctx = ctx
# Element-level widgets reuse the registry. We synthesise a generic
# Property() so factories that read setting.range/.enum/.hint find sane
# defaults instead of touching the original (typed) descriptor.
self._element_setting = Property(None)
self._element_setting.name = "value"
self._element_setting.attr = "_value"
self.collapsed = False
self.collapse_changed = Signal()
# Default width matches the inspector panel (300px); containers may
# resize us via .size, which we re-read in :meth:`process` each frame.
self.size = Vec2(280.0, 0.0)
self._header = SectionHeader(ctx.name, collapsed=False)
self._header.toggled.connect(self._on_header_toggled)
self.add_child(self._header)
self._rows_root = Control()
self.add_child(self._rows_root)
self._add_btn = Button(self._ADD_BTN_TEXT)
self._add_btn.font_size = 10.0
self._add_btn.size = Vec2(60.0, self._ROW_HEIGHT)
self._add_btn.pressed.connect(self._on_add)
self.add_child(self._add_btn)
self._row_widgets: list[Control] = []
self._element_widgets: list[Control] = []
self._proxies: list[_ElementProxy] = []
# Extra controls (e.g. the dict editor's inline key entry) that should
# follow the rows in vertical layout below the add button.
self._extra_controls: list[Control] = []
# -- subclass hooks ------------------------------------------------------
def _iter_keys(self) -> list:
"""Return the current ordered list of keys (subclass-specific)."""
raise NotImplementedError
def _get_element(self, key: Any) -> Any:
raise NotImplementedError
def _set_element_raw(self, key: Any, value: Any) -> None:
"""Mutate the in-memory collection in place; no write-back."""
raise NotImplementedError
def _remove_key(self, key: Any) -> None:
"""Remove ``key`` from the in-memory collection in place."""
raise NotImplementedError
def _row_label(self, key: Any) -> str:
"""Text rendered in the leftmost cell of a row for ``key``."""
raise NotImplementedError
def _clone_collection(self) -> Any:
"""Return a fresh shallow copy of the live collection (for undo)."""
raise NotImplementedError
def _on_add(self) -> None:
"""Bound to the add button. Subclasses choose the UX."""
raise NotImplementedError
# -- mutation ------------------------------------------------------------
def _set_element(self, key: Any, value: Any) -> None:
try:
old_value = self._get_element(key)
except (KeyError, IndexError):
return
if old_value == value:
return
old_collection = self._clone_collection()
self._set_element_raw(key, value)
new_collection = self._clone_collection()
self._write_back(old_collection, new_collection)
def _on_remove(self, key: Any) -> None:
try:
self._get_element(key)
except (KeyError, IndexError):
return
old_collection = self._clone_collection()
self._remove_key(key)
new_collection = self._clone_collection()
self._write_back(old_collection, new_collection)
self._rebuild_rows()
def _write_back(self, old_collection: Any, new_collection: Any) -> None:
# Always assign a fresh collection. Mutating the live container in
# place would share state with the Property's default (which is
# class-level) and leak edits between sibling instances.
setattr(self._ctx.node, self._ctx.name, new_collection)
emit = getattr(self._ctx.inspector, "property_changed", None)
if emit is not None:
emit.emit(self._ctx.node, self._ctx.name, old_collection, new_collection)
# -- layout / drawing ----------------------------------------------------
def _on_header_toggled(self, collapsed: bool) -> None:
self.collapsed = collapsed
self._rows_root.visible = not collapsed
self._add_btn.visible = not collapsed
for ctrl in self._extra_controls:
ctrl.visible = not collapsed
self.collapse_changed.emit(collapsed)
def _rebuild_rows(self) -> None:
for row in self._row_widgets:
self._rows_root.remove_child(row)
self._row_widgets.clear()
self._element_widgets.clear()
self._proxies.clear()
for key in self._iter_keys():
value = self._get_element(key)
proxy = _ElementProxy(self, key)
self._proxies.append(proxy)
row = Control()
row.size = Vec2(self._width(), self._ROW_HEIGHT)
label = Label(self._row_label(key))
label.font_size = 11.0
label.size = Vec2(self._LABEL_W, self._ROW_HEIGHT)
row.add_child(label)
elem_ctx = WidgetContext(
node=proxy,
inspector=self._ctx.inspector,
setting=self._element_setting,
name="value",
value=value,
)
elem_widget = _build_element_widget(elem_ctx)
if elem_widget is None:
elem_widget = Label(repr(value))
elem_widget.font_size = 11.0
elem_widget.size = Vec2(120.0, self._ROW_HEIGHT)
row.add_child(elem_widget)
self._element_widgets.append(elem_widget)
remove_btn = Button("-")
remove_btn.font_size = 10.0
remove_btn.size = Vec2(self._BTN_W, self._ROW_HEIGHT)
remove_btn.pressed.connect(lambda k=key: self._on_remove(k))
row.add_child(remove_btn)
self._rows_root.add_child(row)
self._row_widgets.append(row)
self._update_size()
def _width(self) -> float:
"""Current widget width, falling back to the default 280 px."""
size = getattr(self, "size", None)
if size is None:
return 280.0
return float(size.x) if size.x else 280.0
def _update_size(self) -> None:
rows_h = max(0.0, len(self._row_widgets) * self._ROW_HEIGHT)
header_h = float(self._header.size.y)
extra_h = sum(float(c.size.y) for c in self._extra_controls if c.visible)
total_h = header_h + rows_h + self._ROW_HEIGHT + extra_h + self._PAD
w = max(self._width(), 200.0)
if self.collapsed:
self.size = Vec2(w, header_h)
else:
self.size = Vec2(w, total_h)
def process(self, dt: float) -> None:
# Position header at the top, rows below, and the +Add button last.
w = self._width()
self._header.position = Vec2(0.0, 0.0)
self._header.size = Vec2(w, self._header.size.y)
y = self._header.size.y
self._rows_root.position = Vec2(0.0, y)
rows_h = len(self._row_widgets) * self._ROW_HEIGHT
self._rows_root.size = Vec2(w, rows_h)
for row in self._row_widgets:
row.size = Vec2(w, self._ROW_HEIGHT)
# children: [label, element widget, remove button]
label, elem, btn = row.children[0], row.children[1], row.children[2]
label.position = Vec2(0.0, 0.0)
label.size = Vec2(self._LABEL_W, self._ROW_HEIGHT)
btn_x = w - self._BTN_W
elem_x = self._LABEL_W + self._PAD
elem_w = max(40.0, btn_x - elem_x - self._PAD)
elem.position = Vec2(elem_x, 0.0)
elem.size = Vec2(elem_w, self._ROW_HEIGHT - 2.0)
btn.position = Vec2(btn_x, 0.0)
btn.size = Vec2(self._BTN_W, self._ROW_HEIGHT)
for i, row in enumerate(self._row_widgets):
row.position = Vec2(0.0, i * self._ROW_HEIGHT)
add_y = y + rows_h + self._PAD
self._add_btn.position = Vec2(0.0, add_y)
self._add_btn.size = Vec2(60.0, self._ROW_HEIGHT)
extra_y = add_y + self._ROW_HEIGHT
for ctrl in self._extra_controls:
if not ctrl.visible:
continue
ctrl.position = Vec2(0.0, extra_y)
ctrl.size = Vec2(w, ctrl.size.y)
extra_y += float(ctrl.size.y)
self._update_size()
def draw(self, renderer):
# All visible content is drawn by child widgets; nothing to render here.
del renderer
[docs]
class ListEditor(_CollectionEditor):
"""Inspector widget for ``list``-typed Properties.
Renders a clickable header (collapse/expand) above one row per element.
Each row has an index label, an inline editor (chosen via the registry's
normal type dispatch), and a small ``-`` button that removes the element.
A ``+`` button appended after the rows grows the list using
:func:`_default_for_list` to pick the new element's value.
"""
_LABEL_W = 28.0 # Narrower label cell -- indices are short.
def __init__(self, ctx: WidgetContext):
super().__init__(ctx)
self._values: list = list(ctx.value) if isinstance(ctx.value, list) else []
self._rebuild_rows()
# -- public helpers ------------------------------------------------------
[docs]
@property
def values(self) -> list:
"""Shallow copy of the editor's current list."""
return list(self._values)
# -- collection hooks ----------------------------------------------------
def _iter_keys(self) -> list:
return list(range(len(self._values)))
def _get_element(self, key: int) -> Any:
return self._values[key]
def _set_element_raw(self, key: int, value: Any) -> None:
self._values[key] = value
def _remove_key(self, key: int) -> None:
del self._values[key]
def _row_label(self, key: int) -> str:
return str(key)
def _clone_collection(self) -> list:
return list(self._values)
def _on_add(self) -> None:
old_list = list(self._values)
self._values.append(_default_for_list(self._values))
self._write_back(old_list, list(self._values))
self._rebuild_rows()
[docs]
class DictEditor(_CollectionEditor):
"""Inspector widget for ``dict``-typed Properties.
Mirrors :class:`ListEditor` but keys are user-supplied strings rather than
integer indices. The ``+`` button reveals an inline ``TextEdit`` for the
new key; pressing Enter appends ``{key: <default>}``. Duplicate keys are
rejected via :class:`ValueError` (strict-raise) so callers see the error
rather than silently clobbering an existing entry.
"""
_LABEL_W = 80.0 # Wider label cell -- string keys can be longer.
def __init__(self, ctx: WidgetContext):
super().__init__(ctx)
self._values: dict = dict(ctx.value) if isinstance(ctx.value, dict) else {}
# Inline key entry shown after clicking +. Hidden by default.
self._key_entry = TextEdit(placeholder="key")
self._key_entry.font_size = 11.0
self._key_entry.size = Vec2(160.0, self._ROW_HEIGHT)
self._key_entry.visible = False
self._key_entry.text_submitted.connect(self._on_key_submitted)
self.add_child(self._key_entry)
self._extra_controls.append(self._key_entry)
self._rebuild_rows()
# -- public helpers ------------------------------------------------------
[docs]
@property
def values(self) -> dict:
"""Shallow copy of the editor's current dict."""
return dict(self._values)
# -- collection hooks ----------------------------------------------------
def _iter_keys(self) -> list:
return list(self._values.keys())
def _get_element(self, key: str) -> Any:
return self._values[key]
def _set_element_raw(self, key: str, value: Any) -> None:
self._values[key] = value
def _remove_key(self, key: str) -> None:
del self._values[key]
def _row_label(self, key: str) -> str:
return str(key)
def _clone_collection(self) -> dict:
return dict(self._values)
# -- add UX --------------------------------------------------------------
def _on_add(self) -> None:
# Reveal the inline key entry; commit happens in _on_key_submitted.
self._key_entry.text = ""
self._key_entry.visible = True
self._key_entry.set_focus()
self._update_size()
def _on_key_submitted(self, key: str) -> None:
key = key.strip()
if not key:
self._key_entry.visible = False
self._update_size()
return
if key in self._values:
# Strict-raise so misuse is loud rather than silently clobbering
# an existing entry. Caller (test or editor) sees the error.
raise ValueError(f"duplicate dict key: {key!r}")
old_dict = dict(self._values)
self._values[key] = _default_for_dict(self._values)
self._write_back(old_dict, dict(self._values))
self._key_entry.visible = False
self._rebuild_rows()
# Module-level so the registry can call back into us when building element
# widgets. Set in :func:`default_registry` once the registry is constructed to
# avoid a chicken-and-egg circular reference at import time.
def _null_element_widget(_ctx: WidgetContext) -> Control | None:
return None
_build_element_widget: Callable[[WidgetContext], Control | None] = _null_element_widget
[docs]
class ListPropertyFactory:
"""Factory for list-typed properties.
Matches whenever the current value is a ``list``. Per-element widgets are
built via the same registry so any type the inspector already understands
(``str``, ``int``, ``float``, ``Vec2``/``Vec3``, ``bool``, ...) works
automatically inside a list.
"""
__slots__ = ()
[docs]
def matches(self, ctx: WidgetContext) -> bool:
return isinstance(ctx.value, list)
[docs]
def build(self, ctx: WidgetContext) -> Control | None:
return ListEditor(ctx)
[docs]
class DictPropertyFactory:
"""Factory for dict-typed properties.
Matches whenever the current value is a ``dict``. Per-value widgets are
built via the same registry so any value type the inspector understands
works automatically inside a dict. Keys must be strings.
"""
__slots__ = ()
[docs]
def matches(self, ctx: WidgetContext) -> bool:
return isinstance(ctx.value, dict)
[docs]
def build(self, ctx: WidgetContext) -> Control | None:
return DictEditor(ctx)
# =============================================================================
# Default registry and top-level entry point
# =============================================================================
def _is_tuple_numeric(value: Any, *, require_int: bool = False, require_float: bool = False) -> bool:
if not isinstance(value, tuple):
return False
if len(value) not in (2, 3, 4):
return False
if require_int:
return all(isinstance(v, int) and not isinstance(v, bool) for v in value)
if require_float:
return all(isinstance(v, float) for v in value) and any(isinstance(v, float) for v in value)
return all(isinstance(v, int | float) for v in value)
def _default_registry() -> WidgetRegistry:
r = WidgetRegistry()
# List / dict values -- expandable per-element editors. Registered above
# the generic scalar factories so collection-typed Properties don't fall
# through to the string/tuple fallbacks.
r.register(ListPropertyFactory(), priority=110)
r.register(DictPropertyFactory(), priority=110)
# Typed Property subclasses (highest priority)
r.register(_FnFactory(lambda c: isinstance(c.setting, Colour), _build_colour), priority=100)
r.register(_FnFactory(lambda c: isinstance(c.setting, FilePath), _build_file_path), priority=100)
r.register(_FnFactory(lambda c: isinstance(c.setting, Multiline), _build_multiline), priority=100)
r.register(_FnFactory(lambda c: isinstance(c.setting, Bitmask), _build_bitmask), priority=100)
r.register(_FnFactory(lambda c: isinstance(c.setting, NodePath), _build_node_path), priority=100)
# Enum dropdown
r.register(_FnFactory(lambda c: c.setting.enum is not None, _build_enum), priority=90)
# Bool
r.register(_FnFactory(lambda c: isinstance(c.value, bool), _build_bool), priority=80)
# Quat (non-Vec3 rotation)
r.register(_FnFactory(lambda c: isinstance(c.value, Quat), _build_quat), priority=70)
# Vec2 / Vec3
r.register(_FnFactory(lambda c: isinstance(c.value, Vec2 | Vec3), _build_vec), priority=60)
# Numeric with range -> Slider (int or float)
def _numeric_with_range(c: WidgetContext) -> bool:
if isinstance(c.value, bool):
return False
return isinstance(c.value, int | float) and c.setting.range is not None
r.register(_FnFactory(_numeric_with_range, _build_ranged_number), priority=50)
# Numeric without range -> SpinBox
def _numeric_unranged(c: WidgetContext) -> bool:
if isinstance(c.value, bool):
return False
return isinstance(c.value, int | float)
r.register(_FnFactory(_numeric_unranged, _build_unranged_number), priority=40)
# All-int tuple (viewport size, tilemap cell size)
r.register(_FnFactory(
lambda c: _is_tuple_numeric(c.value, require_int=True),
_build_int_tuple,
), priority=30)
# All-float tuple (non-Colour vector-like)
r.register(_FnFactory(
lambda c: isinstance(c.value, tuple) and len(c.value) in (2, 3, 4)
and all(isinstance(v, int | float) and not isinstance(v, bool) for v in c.value),
_build_float_tuple,
), priority=25)
# String fallback
r.register(_FnFactory(lambda c: isinstance(c.value, str), _build_text), priority=20)
return r
_default_instance: WidgetRegistry | None = None
[docs]
def default_registry() -> WidgetRegistry:
"""Return the lazily-initialised module-level default registry."""
global _default_instance, _build_element_widget
if _default_instance is None:
_default_instance = _default_registry()
registry = _default_instance
def _dispatch(ctx: WidgetContext) -> Control | None:
return registry.build(ctx)
_build_element_widget = _dispatch
return _default_instance
__all__ = [
"DictEditor",
"DictPropertyFactory",
"ListEditor",
"ListPropertyFactory",
"WidgetContext",
"WidgetFactory",
"WidgetRegistry",
"build_widget",
"default_registry",
]
# Suppress unused-import warnings for math imported for potential future needs
_ = math