Source code for simvx.editor.panels.inspector_widgets

"""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 # =============================================================================
[docs] class WidgetFactory(Protocol): """Protocol for widget factories."""
[docs] def matches(self, ctx: WidgetContext) -> bool: ...
[docs] def build(self, ctx: WidgetContext) -> Control | None: ...
@dataclass class _Entry: factory: WidgetFactory priority: int
[docs] @dataclass class WidgetRegistry: """Ordered registry of :class:`WidgetFactory` objects. Factories are scanned highest priority first; the first matching factory wins. Ties are resolved by insertion order. """ _entries: list[_Entry] = field(default_factory=list)
[docs] def register(self, factory: WidgetFactory, *, priority: int = 0) -> None: """Register ``factory`` at the given priority.""" self._entries.append(_Entry(factory, priority)) self._entries.sort(key=lambda e: -e.priority)
[docs] def build(self, ctx: WidgetContext) -> Control | None: """Return the first matching factory's widget, or ``None``.""" for entry in self._entries: if entry.factory.matches(ctx): widget = entry.factory.build(ctx) if widget is not None: return widget return None
# ============================================================================= # 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
[docs] def build_widget( node: Node, inspector: PropertiesPanel, setting: Property, name: str, value: Any, *, registry: WidgetRegistry | None = None, ) -> Control | None: """Build an editor widget for ``(node, name, value)`` via the registry.""" ctx = WidgetContext(node=node, inspector=inspector, setting=setting, name=name, value=value) reg = registry if registry is not None else default_registry() return reg.build(ctx)
__all__ = [ "DictEditor", "DictPropertyFactory", "ListEditor", "ListPropertyFactory", "WidgetContext", "WidgetFactory", "WidgetRegistry", "build_widget", "default_registry", ] # Suppress unused-import warnings for math imported for potential future needs _ = math