"""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_255``).
- :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]
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)
"""
# 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)
expected_len = 4 if has_alpha else 3
if len(default) != expected_len:
if has_alpha and len(default) == 3:
default = (*default, 1.0)
elif not has_alpha and len(default) == 4:
default = default[:3]
super().__init__(default, **kwargs)
self.has_alpha = has_alpha
[docs]
def __set__(self, obj, value):
if isinstance(value, list):
value = tuple(value)
if not isinstance(value, tuple):
raise TypeError(f"{self.name} must be a tuple, got {type(value).__name__}")
if len(value) not in (3, 4):
raise ValueError(f"{self.name} must have 3 or 4 components, got {len(value)}")
if not all(isinstance(v, int | float) for v in value):
raise TypeError(f"{self.name} components must be numeric")
value = tuple(float(v) for v in value)
super().__set__(obj, value)
[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_255(r: int, g: int, b: int, a: int = 255) -> tuple[float, float, float, float]:
"""Create colour from 0-255 integer components."""
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"]