Source code for arcade.types.color

"""Color-related types, aliases, and constants.

This module does not contain pre-defined color values. For pre-made
named color values, please see the following:

.. list-table::
   :header-rows: 1

   * - Module
     - Contents

   * - :py:mod:`arcade.color`
     - A set of pre-defined :py:class`.Color` constants.

   * - :py:mod:`arcade.csscolor`
     - The `CSS named colors <https://developer.mozilla.org/en-US/docs/Web/CSS/named-color>`_
       as :py:class:`.Color` constants.

"""
from __future__ import annotations

import random
from typing import Tuple, Iterable, Optional, Union, TypeVar

from typing_extensions import Self, Final

from arcade.utils import ByteRangeError, IntOutsideRangeError, NormalizedRangeError


__all__ = (
    'Color',
    'RGB',
    'RGBA',
    'RGB255',
    'RGBA255',
    'RGBNormalized',
    'RGBANormalized',
    'RGBOrA',
    'RGBOrA255',
    'RGBOrANormalized',
    'MASK_RGBA_R',
    'MASK_RGBA_G',
    'MASK_RGBA_B',
    'MASK_RGBA_A',
    'MASK_RGB_R',
    'MASK_RGB_G',
    'MASK_RGB_B',
    'MAX_UINT24',
    'MAX_UINT32',
)


# Helpful color-related constants for bit masking
MAX_UINT24: Final[int] = 0xFFFFFF
MAX_UINT32: Final[int] = 0xFFFFFFFF
MASK_RGBA_R: Final[int] = 0xFF000000
MASK_RGBA_G: Final[int] = 0x00FF0000
MASK_RGBA_B: Final[int] = 0x0000FF00
MASK_RGBA_A: Final[int] = 0x000000FF
MASK_RGB_R: Final[int] = 0xFF0000
MASK_RGB_G: Final[int] = 0x00FF00
MASK_RGB_B: Final[int] = 0x0000FF


# Color type aliases.
ChannelType = TypeVar('ChannelType')

# Generic color aliases
RGB = Tuple[ChannelType, ChannelType, ChannelType]
RGBA = Tuple[ChannelType, ChannelType, ChannelType, ChannelType]
RGBOrA = Union[RGB[ChannelType], RGBA[ChannelType]]

# Specific color aliases
RGB255 = RGB[int]
RGBA255 = RGBA[int]
RGBNormalized = RGB[float]
RGBANormalized = RGBA[float]
RGBOrA255 = RGBOrA[int]
RGBOrANormalized = RGBOrA[float]


[docs] class Color(RGBA255): """An RGBA color as a :py:class:`tuple` subclass. .. code-block:: python # The alpha channel value defaults to 255 >>> from arcade.types import Color >>> Color(255, 0, 0) Color(r=255, g=0, b=0, a=255) If you prefer specifying color with another format, the class also provides number of helper methods for the most common RGB and RGBA formats: * :py:meth:`.from_hex_string` * :py:meth:`.from_normalized` * :py:meth:`.from_uint24` * :py:meth:`.from_uint32` * :py:meth:`.from_iterable` Regardless of the source format, all color channels must be between 0 and 255, inclusive. If any channel is outside this range, creation will fail with a :py:class:`~arcade.utils.ByteRangeError`, which is a type of :py:class:`ValueError`. .. _colour: https://pypi.org/project/colour/ .. note:: This class does not currently support HSV or other color spaces. If you need these, you may want to try the following: * Python's built-in :py:mod:`colorsys` module * The `colour`_ package :param r: the red channel of the color, between 0 and 255, inclusive :param g: the green channel of the color, between 0 and 255, inclusive :param b: the blue channel of the color, between 0 and 255, inclusive :param a: the alpha or transparency channel of the color, between 0 and 255, inclusive """ __slots__ = () def __new__(cls, r: int, g: int, b: int, a: int = 255): if not 0 <= r <= 255: raise ByteRangeError("r", r) if not 0 <= g <= 255: raise ByteRangeError("g", g) if not 0 <= g <= 255: raise ByteRangeError("b", b) if not 0 <= a <= 255: raise ByteRangeError("a", a) # Typechecking is ignored because of a mypy bug involving # tuples & super: # https://github.com/python/mypy/issues/8541 return super().__new__(cls, (r, g, b, a)) # type: ignore def __deepcopy__(self, _) -> Self: """Allow :py:func:`~copy.deepcopy` to be used with Color""" return self.__class__(r=self.r, g=self.g, b=self.b, a=self.a) def __repr__(self) -> str: return f"{self.__class__.__name__}(r={self.r}, g={self.g}, b={self.b}, a={self.a})" @property def r(self) -> int: """Get the red value of the :py:class:`Color`. It will be between ``0`` and ``255``, inclusive. """ return self[0] @property def g(self) -> int: """Get the green value of the :py:class:`Color`. It will be between ``0`` and ``255``, inclusive. """ return self[1] @property def b(self) -> int: """Get the blue value of the :py:class:`Color`. It will be between ``0`` and ``255``, inclusive. """ return self[2] @property def a(self) -> int: """Get the alpha value of the :py:class:`Color`. It will be between ``0`` and ``255``, inclusive. """ return self[3] @property def rgb(self) -> Tuple[int, int, int]: """Return only a color's RGB components. This is syntactic sugar for slice indexing as below: .. code-block:: python >>> from arcade.color import WHITE >>> WHITE[:3] (255, 255, 255) # Equivalent but slower than the above >>> (WHITE.r, WHITE.g, WHITE.b) (255, 255, 255) To reorder the channels as you retrieve them, see :meth:`.swizzle`. """ return self[:3]
[docs] @classmethod def from_iterable(cls, iterable: Iterable[int]) -> Self: """Create a :py:class:`Color` from an iterable of 3 or 4 channel values. If an iterable is already a :py:class:`Color` instance, it will be returned unchanged. Otherwise, it must unpack as 3 or 4 :py:class:`int` values between ``0`` and ``255``, inclusive. If an iterable has no less than 3 or more than 4 elements, this method raises a :py:class:`ValueError`. The function will attempt to create a new Color instance. The usual rules apply, i.e.: all values must be between 0 and 255, inclusive. .. note:: This is a more readable alternative to ``*`` unpacking. If you are an advanced user who needs brevity or higher performance, you can unpack directly into :py:class:`Color`: .. code-block:: python >>> rgb_green_tuple = (0, 255, 0) >>> Color(*rgb_green_tuple, 127) Color(r=0, g=255, b=0, a=127) :param iterable: An iterable which unpacks to 3 or 4 elements, each between 0 and 255, inclusive. """ if isinstance(iterable, cls): return iterable # We use unpacking because there isn't a good way of specifying # lengths for sequences as of 3.8, our minimum Python version as # of March 2023: https://github.com/python/typing/issues/786 r, g, b, *_a = iterable if _a: if len(_a) > 1: raise ValueError("iterable must unpack to 3 or 4 values") a = _a[0] else: a = 255 return cls(r, g, b, a=a)
@property def normalized(self) -> RGBANormalized: """Convert the :py:class:`Color` to a tuple of 4 normalized floats. .. code-block:: python >>> arcade.color.WHITE.normalized (1.0, 1.0, 1.0, 1.0) >>> arcade.color.BLACK.normalized (0.0, 0.0, 0.0, 1.0) >>> arcade.color.TRANSPARENT_BLACK.normalized (0.0, 0.0, 0.0, 0.0) """ return self[0] / 255, self[1] / 255, self[2] / 255, self[3] / 255
[docs] @classmethod def from_gray(cls, brightness: int, a: int = 255) -> Self: """Create a gray :py:class:`Color` of the given ``brightness``. .. code-block:: python >>> off_white = Color.from_gray(220) >>> print(off_white) Color(r=220, g=220, b=220, a=255) >>> half_opacity_gray = Color.from_gray(128, 128) >>> print(half_opacity_gray) Color(r=128, g=128, b=128, a=128) :param brightness: How bright the new gray should be :param a: a transparency value, fully opaque by default :return: """ if not 0 <= brightness <= 255: raise ByteRangeError("brightness", brightness) if not 0 <= a <= 255: raise ByteRangeError("a", a) return cls(brightness, brightness, brightness, a=a)
[docs] @classmethod def from_uint24(cls, color: int, a: int = 255) -> Self: """Convert an unsigned 24-bit integer to a :py:class:`Color`. .. code-block:: python # The alpha channel is assumed to be 255 >>> Color.from_uint24(0x010203) Color(r=1, g=2, b=3, a=255) # Specify alpha via the a keyword argument >>> Color.from_uint24(0x010203, a=127) Color(r=1, g=2, b=3, a=127) # The maximum value as decimal >>> Color.from_uint24(16777215) Color(r=255, g=255, b=255, a=255) To convert from an RGBA value as a 32-bit integer, see :py:meth:`.from_uint32`. :param color: a 3-byte :py:class:`int` between ``0`` and ``16777215`` (``0xFFFFFF``) :param a: an alpha value to use between 0 and 255, inclusive. """ if not 0 <= color <= MAX_UINT24: raise IntOutsideRangeError("color", color, 0, MAX_UINT24) if not 0 <= a <= 255: raise ByteRangeError("a", a) return cls( (color & 0xFF0000) >> 16, (color & 0xFF00) >> 8, color & 0xFF, a=a )
[docs] @classmethod def from_uint32(cls, color: int) -> Self: """Convert an unsigned 32-bit integer to a :py:class:`Color`. The four bytes are interpreted as R, G, B, A: .. code-block:: python >>> Color.from_uint32(0x01020304) Color(r=1, g=2, b=3, a=4) # The maximum value as a decimal integer >>> Color.from_uint32(4294967295) Color(r=255, g=255, b=255, a=255) To convert from an RGB value as a 24-bit integer, see :py:meth:`.from_uint24`. :param color: An :py:class:`int` between ``0`` and ``4294967295`` (``0xFFFFFFFF``) """ if not 0 <= color <= MAX_UINT32: raise IntOutsideRangeError("color", color, 0, MAX_UINT32) return cls( (color & 0xFF000000) >> 24, (color & 0xFF0000) >> 16, (color & 0xFF00) >> 8, a=(color & 0xFF) )
[docs] @classmethod def from_normalized(cls, color_normalized: RGBANormalized) -> Self: """Convert normalized float RGBA to an RGBA :py:class:`Color`. If any input channels aren't normalized (between ``0.0`` and ``1.0``), this method will raise a :py:class:`~arcade.utils.NormalizedRangeError` you can handle as a :py:class:`ValueError`. Examples:: >>> Color.from_normalized((1.0, 0.0, 0.0, 1.0)) Color(r=255, g=0, b=0, a=255) >>> normalized_half_opacity_green = (0.0, 1.0, 0.0, 0.5) >>> Color.from_normalized(normalized_half_opacity_green) Color(r=0, g=255, b=0, a=127) :param color_normalized: A tuple of 4 normalized (``0.0`` to ``1.0``) RGBA values. """ r, g, b, *_a = color_normalized if _a: if len(_a) > 1: raise ValueError("color_normalized must unpack to 3 or 4 values") a = _a[0] if not 0.0 <= a <= 1.0: raise NormalizedRangeError("a", a) else: a = 1.0 if not 0.0 <= r <= 1.0: raise NormalizedRangeError("r", r) if not 0.0 <= g <= 1.0: raise NormalizedRangeError("g", g) if not 0.0 <= b <= 1.0: raise NormalizedRangeError("b", b) return cls(int(255 * r), int(255 * g), int(255 * b), a=int(255 * a))
[docs] @classmethod def from_hex_string(cls, code: str) -> Self: """Create a :py:class:`Color` from a hex code of 3, 4, 6, or 8 digits. .. code-block:: python # RGB color codes are assumed to have an alpha value of 255 >>> Color.from_hex_string("#FF00FF") Color(r=255, g=0, b=255, a=255) # You can use eight-digit RGBA codes to specify alpha >>> Color.from_hex_string("#FF007F") Color(r=255, g=0, b=255, a=127) # For brevity, you can omit the # and use RGB shorthand >>> Color.from_hex_string("FFF") Color(r=255, g=255, b=255, a=255) # Lower case and four-digit RGBA shorthand are also allowed >>> Color.from_hex_string("ff0a") Color(r=255, g=255, b=0, a=170) Aside from the optional leading ``#``, the ``code`` must otherwise be a valid CSS hexadecimal color code. It will be processed as follows: * Any leading ``'#'`` characters will be stripped * 3 and 4 digit shorthands are expanded by multiplying each digit's value by 16 * 6 digit RGB hex codes assume 255 as their alpha values * 8 digit RGBA hex codes are converted to byte values and passed directly to a new :py:class:`Color` * All other lengths will raise a :py:class:`ValueError` .. _CSS hex color: https://www.w3.org/TR/css-color-4/#hex-notation .. _Simple Wiki's Hexadecimal Page: https://simple.wikipedia.org/wiki/Hexadecimal To learn more, please see: * Python's :py:func:`hex` function and :py:class:`int` type * `Simple Wiki's Hexadecimal Page`_ * The `CSS hex color`_ specification :param code: A `CSS hex color`_ string which may omit the leading ``#`` character. """ code = code.lstrip("#") # This looks unusual, but it matches CSS color code expansion # behavior for 3 and 4 digit hex codes. if len(code) <= 4: code = "".join(char * 2 for char in code) if len(code) == 6: # full opacity if no alpha specified return cls(int(code[:2], 16), int(code[2:4], 16), int(code[4:6], 16), 255) elif len(code) == 8: return cls(int(code[:2], 16), int(code[2:4], 16), int(code[4:6], 16), int(code[6:8], 16)) raise ValueError(f"Improperly formatted color: '{code}'")
[docs] @classmethod def random( cls, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, a: Optional[int] = None, ) -> Self: """Create a :py:class:`Color` by randomizing all unspecified channels. All arguments are optional. If you specify a channel's value, it will be used in the new color instead of randomizing: .. code-block:: python # Randomize all channels >>> Color.random() Color(r=35, g=145, b=4, a=200) # Create a random opaque color >>> Color.random(a=255) Color(r=25, g=99, b=234, a=255) :param r: Specify a value for the red channel :param g: Specify a value for the green channel :param b: Specify a value for the blue channel :param a: Specify a value for the alpha channel """ rand = random.randint(0, MAX_UINT32) if r is None: r = (rand & MASK_RGBA_R) >> 24 if g is None: g = (rand & MASK_RGBA_G) >> 16 if b is None: b = (rand & MASK_RGBA_B) >> 8 if a is None: a = (rand & MASK_RGBA_A) return cls(r, g, b, a)
[docs] def swizzle(self, order: str) -> Tuple[int, ...]: """Get a :py:class:`tuple` of channel values in the given ``order``. .. _GLSL's swizzling: https://www.khronos.org/opengl/wiki/Data_Type_(GLSL)#Swizzling This imitates `GLSL's swizzling`_, a way to reorder vector values: .. code-block:: python >>> from arcade.types import Color >>> color = Color(180, 90, 0, 255) >>> color.swizzle("abgr") (255, 0, 90, 180) Unlike GLSL, this function allows upper case. .. code-block:: python >>> from arcade.types import Color >>> color = Color(180, 90, 0, 255) # You can repeat channels and use upper case >>> color.swizzle("ABGRa") (255, 0, 90, 180, 255) .. note:: The ``order`` is case-insensitive. If you were hoping ``order`` would also specify how to convert data, you may instead be looking for Python's built-in :py:mod:`struct` and :py:mod:`array` modules. :param order: A string of channel names as letters in ``"RGBArgba"`` with repeats allowed. :return: A tuple of channel values in the given ``order``. """ ret = [] for c in order.lower(): if c not in 'rgba': raise ValueError(f"Swizzle string must only contain characters in [RGBArgba], not {c}.") ret.append(getattr(self, c)) return tuple(ret)