Source code for simvx.core.properties

"""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"]