Source code for term_image.color

"""
.. The Color API
"""

from __future__ import annotations

__all__ = ("Color",)

import re

from typing_extensions import NamedTuple, Self

from .utils import arg_value_error_range

# Unfortunately, `re` doesn't support repeated capturing groups; only the last match
# is captured. So, we'd have to repeat manually.
# Allows mixture of letter case to simplify parsing.
XX = "[0-9a-f]{2}"
_RGBA_HEX_RE = re.compile(rf"#?({XX})({XX})({XX})({XX})?", re.A | re.I)
del XX


# To bypass `NamedTuple`'s `__new__()` override limitation
class _DummyColor(NamedTuple):
    r: int
    g: int
    b: int
    a: int = 255


[docs] class Color(_DummyColor): """A color. Args: r: The red channel. g: The green channel. b: The blue channel. a: The alpha channel (opacity). Raises: ValueError: The value of a channel is not within the valid range. NOTE: The valid value range for all channels is 0 to 255, both inclusive. TIP: This class is a :py:class:`~typing.NamedTuple` of four fields. WARNING: In the case of multiple inheritance i.e if subclassing this class along with other classes, this class should appear last (i.e to the far right) in the base class list. """ __slots__ = () # Overrides these descriptors in order to speed up attribute resolution and to # simplify auto documentation. r: int = _DummyColor.r r.__doc__ = """The red channel""" g: int = _DummyColor.g g.__doc__ = """The green channel""" b: int = _DummyColor.b b.__doc__ = """The blue channel""" a: int = _DummyColor.a a.__doc__ = """The alpha channel (opacity)""" def __new__(cls, r: int, g: int, b: int, a: int = 255) -> Self: # Only the 8 LSb may be set for any value within the range [0, 255]. # `x & ~255` unsets the 8 LSb. Hence, if the result is non-zero (i.e any # of the bits above the lowest 8 is set), it implies `x` is out of range. # # Actually benchmarked *this* against the "simpler" logically-negated chained # comparison (i.e `0 <= x <= 255`) and *this* was significantly faster for all # cases i.e within, on the boundaries, and outside (on both sides). if (r | g | b | a) & ~255: # First test to see if *any* is out of range if r & ~255: raise arg_value_error_range("r", r) if g & ~255: raise arg_value_error_range("g", g) if b & ~255: raise arg_value_error_range("b", b) if a & ~255: raise arg_value_error_range("a", a) # Using `tuple` directly instead of `super()` for performance return tuple.__new__(cls, (r, g, b, a)) @property def hex(self) -> str: """Converts the color to its RGBA hexadecimal representation. Returns: The RGBA hex color string, starting with the ``#`` character i.e ``#rrggbbaa``. Each channel is represented by two **lowercase** hex digits ranging from ``00`` to ``ff``. """ return "#%02x%02x%02x%02x" % self @property def rgb(self) -> tuple[int, int, int]: """Extracts the R, G and B channels of the color. Returns: A 3-tuple containing the red, green and blue channel values. """ return self[:3] @property def rgb_hex(self) -> str: """Converts the color to its RGB hexadecimal representation. Returns: The RGB hex color string, starting with the ``#`` character i.e ``#rrggbb``. Each channel is represented by two **lowercase** hex digits ranging from ``00`` to ``ff``. """ return "#%02x%02x%02x" % self[:3]
[docs] @classmethod def from_hex(cls, color: str) -> Self: """Creates a new instance from a hexadecimal color string. Args: color: A **case-insensitive** RGB or RGBA hex color string, **optionally** starting with the ``#`` (pound) character i.e ``[#]rrggbb[aa]``. Returns: A new instance representing the given hex color string. Raises: ValueError: Invalid hex color string. NOTE: For an RGB hex color string, the value of A (the alpha channel) is taken to be 255. """ if not (match := _RGBA_HEX_RE.fullmatch(color)): raise ValueError(f"Invalid hex color string (got: {color!r})") return tuple.__new__(cls, [int(x, 16) for x in match.groups("ff")])
@classmethod def _new(cls, r: int, g: int, b: int, a: int = 255) -> Self: """Alternate constructor for internal use only.""" return tuple.__new__(cls, (r, g, b, a))
_Color = Color._new