"""Typed Property subclasses for editor-visible values.
These subclasses extend :class:`simvx.core.descriptors.Property` with type-specific
metadata so the editor can dispatch the correct widget without relying on name
heuristics or tuple-shape guessing. Stored values remain primitive (str, int,
tuple) so scene serialisation is unaffected.
The five subclasses are:
- :class:`Colour` -- RGB/RGBA tuple with optional alpha channel. Also carries
a palette of named colour constants (``Colour.RED`` etc.)
and factory helpers (``Colour.hex``, ``Colour.rgba``,
``Colour.from_rgb255``).
- :class:`FilePath` -- File / resource path with filter and relative base.
- :class:`Multiline` -- Multi-line string with optional syntax hint.
- :class:`Bitmask` -- N-bit integer with optional per-bit names.
- :class:`NodePath` -- Scene-relative node path with optional type filter.
All five accept the existing ``hint=``, ``group=``, ``on_change=``, ``link=``,
``propagate=`` kwargs exactly like :class:`Property`.
"""
from .descriptors import Property
[docs]
def set_mask_bit(value: int, index: int, enabled: bool, *, label: str = "Layer") -> int:
"""Return ``value`` with bit ``index`` (0-31) set or cleared per ``enabled``.
Shared by the engine's render-layer / cull-mask toggles. ``label`` names the
field in the range-check error message.
"""
if not 0 <= index < 32:
raise ValueError(f"{label} index must be 0-31, got {index}")
return value | (1 << index) if enabled else value & ~(1 << index)
[docs]
def get_mask_bit(value: int, index: int, *, label: str = "Layer") -> bool:
"""Return whether bit ``index`` (0-31) is set in ``value``."""
if not 0 <= index < 32:
raise ValueError(f"{label} index must be 0-31, got {index}")
return bool(value & (1 << index))
def _fit_colour_arity(value: tuple[float, ...], *, has_alpha: bool, name: str) -> tuple[float, ...]:
"""Return ``value`` as an RGBA tuple when ``has_alpha``, else an RGB tuple.
RGB widens to RGBA with an opaque alpha, and RGBA narrows to RGB when its
alpha is already opaque: both conversions are information-free. Narrowing a
translucent RGBA value would not be, so it raises instead of quietly
rendering the colour fully opaque.
"""
if has_alpha and len(value) == 3:
return (*value, 1.0)
if not has_alpha and len(value) == 4:
if value[3] != 1.0:
raise ValueError(
f"{name} has no alpha channel, so the translucent value {value} cannot be stored; "
"pass an RGB triple, or an RGBA value with alpha 1.0"
)
return value[:3]
return value
[docs]
class Colour(Property):
"""RGB or RGBA colour property (tuple of floats in [0, 1]).
Also exposes a palette of common colour constants and hex/rgba factory
helpers for use anywhere an RGBA tuple is expected.
Example as a Property::
class Light(Node3D):
tint = Colour((1.0, 0.5, 0.0, 1.0))
ambient = Colour((0.1, 0.1, 0.15), has_alpha=False)
Example as a palette::
panel.bg_colour = Colour.RED
label.text_colour = Colour.hex("#FF6600")
button.bg_colour = Colour.rgba(0.2, 0.4, 0.8)
Assigning ``None`` to a :class:`Colour` Property reverts the value to the
descriptor's declared default (mirroring :class:`ThemeColour`'s "no
override" semantics). Widget setters that accept ``Colour.coerce`` output
(``Panel.bg_colour`` etc.) treat ``None`` as "use the theme default".
A stored value always has the arity the property declares, so downstream
code can unpack it. An RGB value assigned to an RGBA property gains an
opaque alpha, and an opaque RGBA value assigned to an RGB property drops
it, which is why the palette constants (all RGBA) work on an alpha-less
property. A *translucent* RGBA value assigned to an RGB property raises:
that alpha is real data and silently discarding it would render the wrong
thing.
"""
# Palette constants (RGBA tuples in [0, 1])
WHITE = (1.0, 1.0, 1.0, 1.0)
BLACK = (0.0, 0.0, 0.0, 1.0)
RED = (1.0, 0.0, 0.0, 1.0)
GREEN = (0.0, 1.0, 0.0, 1.0)
BLUE = (0.0, 0.0, 1.0, 1.0)
YELLOW = (1.0, 1.0, 0.0, 1.0)
CYAN = (0.0, 1.0, 1.0, 1.0)
MAGENTA = (1.0, 0.0, 1.0, 1.0)
TRANSPARENT = (0.0, 0.0, 0.0, 0.0)
GRAY = (0.5, 0.5, 0.5, 1.0)
DARK_GRAY = (0.2, 0.2, 0.2, 1.0)
LIGHT_GRAY = (0.75, 0.75, 0.75, 1.0)
ORANGE = (1.0, 0.6, 0.0, 1.0)
PURPLE = (0.6, 0.2, 0.8, 1.0)
PINK = (1.0, 0.4, 0.7, 1.0)
__slots__ = ("has_alpha",)
def __init__(self, default: tuple = (1.0, 1.0, 1.0, 1.0), *, has_alpha: bool = True, **kwargs):
if not isinstance(default, tuple | list):
raise TypeError(f"Colour default must be a tuple/list, got {type(default).__name__}")
if len(default) not in (3, 4):
raise ValueError(f"Colour default must have 3 or 4 components, got {len(default)}")
default = tuple(float(v) for v in default)
default = _fit_colour_arity(default, has_alpha=has_alpha, name="Colour default")
super().__init__(default, **kwargs)
self.has_alpha = has_alpha
[docs]
def __set__(self, obj, value):
value = Colour.coerce(value, name=self.name, has_alpha=self.has_alpha)
if value is None:
# ``None`` means "no override": revert to the descriptor's
# default, matching :class:`ThemeColour.__set__` semantics.
value = self.default
super().__set__(obj, value)
[docs]
@staticmethod
def coerce(value, *, name: str = "colour", has_alpha: bool = True) -> tuple[float, ...] | None:
"""Coerce a colour-ish value to a float tuple of the requested arity, or ``None``.
Accepts:
- ``None`` -- sentinel meaning "no override, use the theme default".
Returned as ``None`` so callers can fall back to a theme-resolved
colour. Raw draw callsites that cannot tolerate ``None`` must
check the result and either resolve it or raise loudly.
- ``tuple`` / ``list`` of 3 or 4 numeric components
- a :class:`Colour` Property instance (uses its ``default`` tuple)
``has_alpha`` selects the arity of the result: RGBA by default, RGB
when ``False``. An RGB input is widened with an opaque alpha, and an
opaque RGBA input is narrowed by dropping it. A translucent RGBA input
cannot be narrowed and raises instead.
Raises ``TypeError`` / ``ValueError`` for anything else.
"""
# ``None`` is the canonical "no override" sentinel, mirroring
# :class:`ThemeColour`'s setter. Pass-through so callers can decide
# how to resolve it (theme lookup, error, etc.).
if value is None:
return None
# Unwrap a Property instance (e.g. ``Colour((r, g, b, a))`` passed at runtime).
if isinstance(value, Colour):
value = value.default
if isinstance(value, list):
value = tuple(value)
if not isinstance(value, tuple):
raise TypeError(f"{name} must be a tuple, got {type(value).__name__}")
if len(value) not in (3, 4):
raise ValueError(f"{name} must have 3 or 4 components, got {len(value)}")
if not all(isinstance(v, int | float) for v in value):
raise TypeError(f"{name} components must be numeric")
return _fit_colour_arity(tuple(float(v) for v in value), has_alpha=has_alpha, name=name)
[docs]
@staticmethod
def hex(h: str) -> tuple[float, float, float, float]:
"""Parse hex colour string ``'#RRGGBB'`` / ``'#RRGGBBAA'`` into RGBA tuple."""
h = h.lstrip("#")
r = int(h[0:2], 16) / 255
g = int(h[2:4], 16) / 255
b = int(h[4:6], 16) / 255
a = int(h[6:8], 16) / 255 if len(h) >= 8 else 1.0
return (r, g, b, a)
[docs]
@staticmethod
def rgba(r: float, g: float, b: float, a: float = 1.0) -> tuple[float, float, float, float]:
"""Create colour from float components (0.0-1.0)."""
return (r, g, b, a)
[docs]
@staticmethod
def from_rgb255(r: int, g: int, b: int, a: int = 255) -> tuple[float, float, float, float]:
"""Create colour from 0-255 integer components (alpha defaults to 255 = opaque).
Returns a canonical 0-1 RGBA float tuple. This is the designer-ergonomic
entry point for callers who think in 0-255: the rest of the engine
speaks float 0-1 (HDR-ready, shader-native).
"""
return (r / 255, g / 255, b / 255, a / 255)
[docs]
class FilePath(Property):
"""File / resource path property.
Example::
icon = FilePath("", filter="*.png;*.jpg")
"""
__slots__ = ("filter", "relative_to")
def __init__(self, default: str = "", *, filter: str = "*.*", relative_to: str | None = None, **kwargs):
if default is not None and not isinstance(default, str):
raise TypeError(f"FilePath default must be str or None, got {type(default).__name__}")
super().__init__(default, **kwargs)
self.filter = filter
self.relative_to = relative_to
[docs]
def __set__(self, obj, value):
if value is not None and not isinstance(value, str):
raise TypeError(f"{self.name} must be str or None, got {type(value).__name__}")
super().__set__(obj, value)
[docs]
class Multiline(Property):
"""Multi-line string property with optional syntax hint.
When ``syntax='python'`` the editor uses its code editor widget; otherwise
it uses the plain multi-line text editor.
Example::
description = Multiline("", min_lines=4)
script_body = Multiline("", syntax="python")
"""
__slots__ = ("min_lines", "syntax")
def __init__(self, default: str = "", *, min_lines: int = 3, syntax: str | None = None, **kwargs):
if not isinstance(default, str):
raise TypeError(f"Multiline default must be str, got {type(default).__name__}")
super().__init__(default, **kwargs)
self.min_lines = int(min_lines)
self.syntax = syntax
[docs]
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be str, got {type(value).__name__}")
super().__set__(obj, value)
[docs]
class Bitmask(Property):
"""Bit flag integer property with optional per-bit names.
Stores an ordinary int. The editor renders a grid of ``bits`` toggles
arranged as ``bits // 8`` rows of 8.
Example::
collision_layer = Bitmask(1, bits=32, group="Collision")
"""
__slots__ = ("bits", "names")
def __init__(self, default: int = 0, *, bits: int = 32, names: list[str] | None = None, **kwargs):
if bits <= 0 or bits % 8 != 0:
raise ValueError(f"Bitmask bits must be a positive multiple of 8, got {bits}")
if names is not None and len(names) != bits:
raise ValueError(f"Bitmask names must have exactly {bits} entries, got {len(names)}")
kwargs.setdefault("range", (0, (1 << bits) - 1))
super().__init__(int(default), **kwargs)
self.bits = bits
self.names = list(names) if names else None
[docs]
def __set__(self, obj, value):
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{self.name} must be int, got {type(value).__name__}")
super().__set__(obj, int(value))
[docs]
class NodePath(Property):
"""Scene-relative node path property.
Stores a path string (e.g. ``"../Camera2D"``). ``type_filter`` is an
optional Node subclass used by the editor picker to grey out nodes that
would be invalid targets.
Example::
remote_path = NodePath("", type_filter=Node2D)
"""
__slots__ = ("type_filter",)
def __init__(self, default: str = "", *, type_filter: type | None = None, **kwargs):
if not isinstance(default, str):
raise TypeError(f"NodePath default must be str, got {type(default).__name__}")
super().__init__(default, **kwargs)
self.type_filter = type_filter
[docs]
def __set__(self, obj, value):
if value is None:
value = ""
if not isinstance(value, str):
raise TypeError(f"{self.name} must be str, got {type(value).__name__}")
super().__set__(obj, value)
__all__ = ["Bitmask", "Colour", "FilePath", "Multiline", "NodePath", "get_mask_bit", "set_mask_bit"]