diff --git a/pyface/color.py b/pyface/color.py index cdba22ab4..47e3b1eb9 100644 --- a/pyface/color.py +++ b/pyface/color.py @@ -25,8 +25,8 @@ Bool, HasStrictTraits, Property, Range, Tuple, cached_property ) +from pyface.color_tuple import ColorTuple from pyface.util.color_helpers import channels_to_ints, is_dark -from pyface.util.color_helpers import ints_to_channels # noqa: F401 from pyface.util.color_parser import parse_text @@ -55,8 +55,7 @@ class Color(HasStrictTraits): Colors implement equality testing, but are not hashable as they are mutable, and so are not suitable for use as dictionary keys. If you - need a dictionary key, use an appropriate channel tuple from the - object. + need a dictionary key, use a ColorTuple. """ #: A tuple holding the red, green, blue, and alpha channels. @@ -93,7 +92,7 @@ class Color(HasStrictTraits): is_dark = Property(Bool, observe='rgba') @classmethod - def from_str(cls, text, **traits): + def from_str(cls, text: str, **traits): """ Create a new Color object from a string. Parameters @@ -154,7 +153,17 @@ def to_toolkit(self): rgba_to_toolkit_color = toolkit_object('color:rgba_to_toolkit_color') return rgba_to_toolkit_color(self.rgba) - def hex(self): + def to_color_tuple(self) -> ColorTuple: + """ Create a new Color object from a ColorTuple. + + Returns + ------- + color_tuple : ColorTuple + A ColorTuple instance. + """ + return ColorTuple(*self.rgba) + + def hex(self) -> str: """ Provide a hex representation of the Color object. Note that because the hex value is restricted to 0-255 integer values diff --git a/pyface/color_tuple.py b/pyface/color_tuple.py new file mode 100644 index 000000000..e5ad83c4b --- /dev/null +++ b/pyface/color_tuple.py @@ -0,0 +1,168 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Color classes and corresponding trait types for Pyface. + +The base Color class holds red, green, blue and alpha channel values as +a tuple of normalized values from 0.0 to 1.0. Various property traits +pull out the individual channel values and supply values for the HSV +and HSL colour spaces (with and without alpha). + +The ``from_toolkit`` and ``to_toolkit`` methods allow conversion to and +from native toolkit color objects. +""" + +import colorsys +from typing import NamedTuple, Tuple, TYPE_CHECKING + +from pyface.util.color_helpers import ( + channel, channels_to_ints, is_dark, RGBTuple +) +from pyface.util.color_parser import parse_text + +if TYPE_CHECKING: + from .color import Color + + +class ColorTuple(NamedTuple): + """An immutable specification of a color with alpha. + + This is a namedtuple designed to be used by user interface elements which + need to color some or all of the interface element. Each color has a + number of different representations as channel tuples, each channel + holding a value between 0.0 and 1.0, inclusive. The standard red, + green, blue and alpha channels are also provided as convenience + properties. + + Methods are provided to convert to and from toolkit-specific color + objects. + + ColorTuples can be tested for equality and are hashable, just as with all + namedtuples, and so are suitable for use as dictionary keys. + """ + + red: channel = channel(1.0) + + green: channel = channel(1.0) + + blue: channel = channel(1.0) + + alpha: channel = channel(1.0) + + @classmethod + def from_str(cls, text: str): + """ Create a new ColorTuple from a string. + + Parameters + ---------- + text : str + A string holding the representation of the color. This can be: + + - a color name, including all CSS color names, plus any additional + names found in pyface.color.color_table. The names are + normalized to lower case and stripped of whitespace, hyphens and + underscores. + + - a hex representation of the color in the form '#RGB', '#RGBA', + '#RRGGBB', '#RRGGBBAA', '#RRRRGGGGBBBB', or '#RRRRGGGGBBBBAAAA'. + + Raises + ------ + ColorParseError + If the string cannot be converted to a valid color. + """ + space, channels = parse_text(text) + return cls(*channels) + + @classmethod + def from_color(cls, color: "Color") -> "ColorTuple": + """ Create a new ColorTuple from a Color object. + + Parameters + ---------- + color : Color + A Color object. + """ + return cls(*color.rgba) + + @classmethod + def from_toolkit(cls, toolkit_color) -> "ColorTuple": + """ Create a new RGBAColorTuple from a toolkit color object. + + Parameters + ---------- + toolkit_color : toolkit object + A toolkit color object, such as a Qt QColor or a Wx wx.Colour. + **traits + Any additional trait values to be passed as keyword arguments. + """ + from pyface.toolkit import toolkit_object + toolkit_color_to_rgba = toolkit_object('color:toolkit_color_to_rgba') + return cls(*toolkit_color_to_rgba(toolkit_color)) + + def to_toolkit(self): + """ Create a new toolkit color object from a Color object. + + Returns + ------- + toolkit_color : toolkit object + A toolkit color object, such as a Qt QColor or a Wx wx.Colour. + """ + from pyface.toolkit import toolkit_object + rgba_to_toolkit_color = toolkit_object('color:rgba_to_toolkit_color') + return rgba_to_toolkit_color(self) + + def hex(self) -> str: + """ Provide a hex representation of the Color object. + + Note that because the hex value is restricted to 0-255 integer values + for each channel, the representation is not exact. + + Returns + ------- + hex : str + A hex string in standard ``#RRGGBBAA`` format that represents + the color. + """ + values = channels_to_ints(self) + return "#{:02X}{:02X}{:02X}{:02X}".format(*values) + + def __str__(self) -> str: + return "({:0.5}, {:0.5}, {:0.5}, {:0.5})".format(*self) + + @property + def rgb(self) -> RGBTuple: + return self[:3] + + @property + def hsva(self) -> Tuple[channel, channel, channel, channel]: + r, g, b, a = self + h, s, v = colorsys.rgb_to_hsv(r, g, b) + return (h, s, v, a) + + @property + def hsv(self) -> Tuple[channel, channel, channel]: + r, g, b, a = self + return colorsys.rgb_to_hsv(r, g, b) + + @property + def hlsa(self) -> Tuple[channel, channel, channel, channel]: + r, g, b, a = self + h, l, s = colorsys.rgb_to_hls(r, g, b) + return (h, l, s, a) + + @property + def hls(self) -> Tuple[channel, channel, channel]: + r, g, b, a = self + return colorsys.rgb_to_hls(r, g, b) + + @property + def is_dark(self) -> bool: + return is_dark(self) diff --git a/pyface/tests/test_color.py b/pyface/tests/test_color.py index c9d6834e2..b6ea8f1f3 100644 --- a/pyface/tests/test_color.py +++ b/pyface/tests/test_color.py @@ -13,6 +13,7 @@ from traits.testing.api import UnittestTools from pyface.color import Color +from pyface.color_tuple import ColorTuple class TestColor(UnittestTools, TestCase): @@ -81,6 +82,11 @@ def test_toolkit_round_trip(self): result = Color.from_toolkit(toolkit_color) self.assertEqual(result.rgba, (0.4, 0.2, 0.6, 0.8)) + def test_to_color_tuple(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color_tuple = color.to_color_tuple() + self.assertEqual(color_tuple, ColorTuple(0.4, 0.2, 0.6, 0.8)) + def test_hex(self): color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) hex_value = color.hex() diff --git a/pyface/tests/test_color_tuple.py b/pyface/tests/test_color_tuple.py new file mode 100644 index 000000000..784b9cc92 --- /dev/null +++ b/pyface/tests/test_color_tuple.py @@ -0,0 +1,101 @@ +# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase + +from pyface.color import Color +from pyface.color_tuple import ColorTuple + + +class TestColorTuple(TestCase): + + def assert_tuple_almost_equal(self, tuple_1, tuple_2): + self.assertEqual(len(tuple_1), len(tuple_2)) + + for x, y in zip(tuple_1, tuple_2): + self.assertAlmostEqual(x, y) + + def test_init(self): + color = ColorTuple() + self.assertEqual(color, (1.0, 1.0, 1.0, 1.0)) + + def test_init_rgba(self): + color = ColorTuple(0.4, 0.2, 0.6, 0.8) + self.assertEqual(color, (0.4, 0.2, 0.6, 0.8)) + + def test_init_rgb(self): + color = ColorTuple(0.4, 0.2, 0.6) + self.assertEqual(color, (0.4, 0.2, 0.6, 1.0)) + + def test_init_r_g_b_a(self): + color = ColorTuple(red=0.4, green=0.2, blue=0.6, alpha=0.8) + self.assertEqual(color, (0.4, 0.2, 0.6, 0.8)) + + def test_init_r_g_b(self): + color = ColorTuple(red=0.4, green=0.2, blue=0.6) + self.assertEqual(color, (0.4, 0.2, 0.6, 1.0)) + + def test_from_str_name(self): + color = ColorTuple.from_str('rebeccapurple') + self.assertEqual(color, (0.4, 0.2, 0.6, 1.0)) + + def test_from_str_hex(self): + color = ColorTuple.from_str('#663399ff') + self.assertEqual(color, (0.4, 0.2, 0.6, 1.0)) + + def test_toolkit_round_trip(self): + color = ColorTuple(0.4, 0.2, 0.6, 0.8) + toolkit_color = color.to_toolkit() + result = ColorTuple.from_toolkit(toolkit_color) + self.assertEqual(result, color) + + def test_from_color(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color_tuple = ColorTuple.from_color(color) + self.assertEqual(color_tuple, (0.4, 0.2, 0.6, 0.8)) + + def test_hex(self): + color = ColorTuple(0.4, 0.2, 0.6, 0.8) + hex_value = color.hex() + self.assertEqual(hex_value, "#663399CC") + + def test_hex_black(self): + color = ColorTuple(0.0, 0.0, 0.0, 1.0) + hex_value = color.hex() + self.assertEqual(hex_value, "#000000FF") + + def test_str(self): + color = ColorTuple(0.4, 0.2, 0.6, 0.8) + result = str(color) + self.assertEqual(result, "(0.4, 0.2, 0.6, 0.8)") + + def test_get_rgb(self): + color = ColorTuple(0.4, 0.2, 0.6, 0.8) + self.assertEqual(color.rgb, (0.4, 0.2, 0.6)) + + def test_get_hsv(self): + color = ColorTuple(0.48, 0.6, 0.528, 0.8) + self.assert_tuple_almost_equal(color.hsv, (0.4, 0.2, 0.6)) + + def test_get_hsva(self): + color = ColorTuple(0.48, 0.6, 0.528, 0.8) + self.assert_tuple_almost_equal(color.hsva, (0.4, 0.2, 0.6, 0.8)) + + def test_get_hls(self): + color = ColorTuple(0.08, 0.32, 0.176, 0.8) + self.assert_tuple_almost_equal(color.hls, (0.4, 0.2, 0.6)) + + def test_get_hlsa(self): + color = ColorTuple(0.08, 0.32, 0.176, 0.8) + self.assert_tuple_almost_equal(color.hlsa, (0.4, 0.2, 0.6, 0.8)) + + def test_get_is_dark(self): + color = ColorTuple(0.08, 0.32, 0.176, 0.8) + self.assertTrue(color.is_dark) diff --git a/pyface/ui/qt/color.py b/pyface/ui/qt/color.py index 0888d32e8..d7e219ccf 100644 --- a/pyface/ui/qt/color.py +++ b/pyface/ui/qt/color.py @@ -13,12 +13,15 @@ pyface.color.Color class to_toolkit and from_toolkit methods. """ -from pyface.qt.QtGui import QColor +from typing import cast -from pyface.color import channels_to_ints, ints_to_channels +from pyface.qt.QtGui import QColor +from pyface.util.color_helpers import ( + channels_to_ints, ints_to_channels, RGBATuple, +) -def toolkit_color_to_rgba(qcolor): +def toolkit_color_to_rgba(qcolor: QColor) -> RGBATuple: """ Convert a QColor to an RGBA tuple. Parameters @@ -37,10 +40,10 @@ def toolkit_color_to_rgba(qcolor): qcolor.blue(), qcolor.alpha(), ) - return ints_to_channels(values) + return cast(RGBATuple, ints_to_channels(values)) -def rgba_to_toolkit_color(rgba): +def rgba_to_toolkit_color(rgba: RGBATuple) -> QColor: """ Convert an RGBA tuple to a QColor. Parameters diff --git a/pyface/ui/wx/color.py b/pyface/ui/wx/color.py index 55803c042..f3aa0f821 100644 --- a/pyface/ui/wx/color.py +++ b/pyface/ui/wx/color.py @@ -16,10 +16,12 @@ import wx -from pyface.color import channels_to_ints, ints_to_channels +from pyface.util.color_helpers import ( + channels_to_ints, ints_to_channels, RGBATuple +) -def toolkit_color_to_rgba(wx_colour): +def toolkit_color_to_rgba(wx_colour: wx.Colour) -> RGBATuple: """ Convert a wx.Colour to an RGBA tuple. Parameters @@ -41,7 +43,7 @@ def toolkit_color_to_rgba(wx_colour): return ints_to_channels(values) -def rgba_to_toolkit_color(rgba): +def rgba_to_toolkit_color(rgba: RGBATuple) -> wx.Colour: """ Convert an RGBA tuple to a wx.Colour. Parameters diff --git a/pyface/util/color_helpers.py b/pyface/util/color_helpers.py index 10964297f..1e4174dee 100644 --- a/pyface/util/color_helpers.py +++ b/pyface/util/color_helpers.py @@ -15,8 +15,25 @@ that code. """ +from typing import cast, Iterable, NewType, Tuple, Type, Union -def channels_to_ints(channels, maximum=255): +from typing_extensions import TypeGuard + + +#: A type representing a channel value between 0.0 and 1.0. +channel = NewType("channel", float) + +#: A tuple of red, green, blue channels. +RGBTuple = Tuple[channel, channel, channel] + +#: A tuple of red, green, blue, alpha channels. +RGBATuple = Tuple[channel, channel, channel, channel] + + +def channels_to_ints( + channels: Iterable[channel], + maximum: int = 255, +) -> Tuple[int, ...]: """ Convert an iterable of floating point channel values to integers. Values are rounded to the nearest integer, rather than truncated. @@ -38,7 +55,10 @@ def channels_to_ints(channels, maximum=255): return tuple(int(round(channel * maximum)) for channel in channels) -def ints_to_channels(values, maximum=255): +def ints_to_channels( + values: Iterable[int], + maximum: int = 255, +) -> Tuple[channel, ...]: """ Convert an iterable of integers to floating point channel values. Parameters @@ -55,10 +75,10 @@ def ints_to_channels(values, maximum=255): A tuple of channel values, each value between 0.0 and 1.0, inclusive. """ - return tuple(value / maximum for value in values) + return tuple(channel(value / maximum) for value in values) -def relative_luminance(rgb): +def relative_luminance(rgb: RGBTuple) -> float: """ The relative luminance of the color. This value is the critical value when comparing colors for contrast when @@ -92,7 +112,7 @@ def relative_luminance(rgb): return luminance -def is_dark(rgb): +def is_dark(rgb: RGBTuple) -> bool: """ Is the color dark to human perception? A color is dark if white contasts better with it according to the WC3 @@ -106,6 +126,12 @@ def is_dark(rgb): A tuple of values representing red, green and blue components of the color, as values from 0.0 to 1.0. + Returns + ------- + is_dark : bool + Whether the contrast against white is greater than the contrast against + black. + References ---------- Understanding Web Contrast Accessibility Guidelines @@ -115,3 +141,101 @@ def is_dark(rgb): black_contrast = (lumininance + 0.05) / 0.05 white_contrast = 1.05 / (lumininance + 0.05) return white_contrast > black_contrast + + +def int_to_color_tuple(value: int) -> RGBTuple: + """Convert an int to a color, assuming a hex value of 0xRRGGBB + + Values outside 0, ..., 0xFFFFFF will raise a ValueError + + This is largely made available for backwards compatibility with old ETS + color traits. + + Parameters + ---------- + value : int + Integer value of the form 0xRRGGBB + + Returns + ------- + color : RGB tuple + A tuple of RGB values from 0.0 to 1.0. + """ + if 0 <= value <= 0xFFFFFF: + return cast(RGBTuple, ints_to_channels( + (value >> 16, (value >> 8) & 0xFF, value & 0xFF) + )) + else: + raise ValueError( + f"RGB integer value {value!r} must be between 0 and 0xFFFFFF" + ) + + +def sequence_to_rgba_tuple(value: Iterable[Union[float, int]]) -> RGBATuple: + """Convert a sequence type to a tuple of RGB(A) value from 0.0 to 1.0 + + This handles converson of 0, ..., 255 integer values and adding an alpha + channel of 1.0, if needed. + + Parameters + ---------- + value : sequence of ints or floats + A sequence of length 3 or 4 of either integer values from 0 to 255, or + floating point values from 0.0 to 1.0. + + Returns + ------- + rgba_tuple : tuple of floats between 0.0 and 1.0 + A tuple of RGBA channel values, each value between 0.0 and 1.0, + inclusive. + + Raises + ------ + ValueError + Raised if the sequence is of the wrong length or contains out-of-bounds + values. + """ + value = tuple(value) + if _is_int_tuple(value): + if all(0 <= x < 256 for x in value): + channel_tuple = ints_to_channels(value) + else: + raise ValueError( + f"Integer sequence values not in range 0 to 255: {value!r}" + ) + else: + channel_tuple = tuple(channel(x) for x in value) + + if _is_rgb_tuple(channel_tuple): + rgba_tuple = channel_tuple + (channel(1.0),) + elif _is_rgba_tuple(channel_tuple): + rgba_tuple = channel_tuple + else: + raise ValueError("Sequence {value!r} must have length 3 or 4.") + + if all(0 <= x <= 1.0 for x in rgba_tuple): + return rgba_tuple + else: + raise ValueError( + f"Float sequence values not in range 0 to 1: {value!r}" + ) + + +def _is_int_tuple( + value: Tuple[Union[float, int], ...] +) -> TypeGuard[Tuple[int, ...]]: + int_types: Tuple[Type, ...] + try: + import numpy as np + int_types = (int, np.integer) + except ImportError: + int_types = (int,) + return all(isinstance(x, int_types) for x in value) + + +def _is_rgb_tuple(value: Tuple[channel, ...]) -> TypeGuard[RGBTuple]: + return len(value) == 3 + + +def _is_rgba_tuple(value: Tuple[channel, ...]) -> TypeGuard[RGBATuple]: + return len(value) == 4 diff --git a/pyface/util/color_parser.py b/pyface/util/color_parser.py index d7d0d3939..517cb1abd 100644 --- a/pyface/util/color_parser.py +++ b/pyface/util/color_parser.py @@ -24,12 +24,20 @@ """ import re +from typing import cast, Dict, Optional, Tuple, Union -from .color_helpers import ints_to_channels +from .color_helpers import ints_to_channels, RGBATuple, RGBTuple, _is_rgb_tuple, _is_rgba_tuple + + +# the type signature of a parser return value +ParserReturnValue = Tuple[str, Union[RGBTuple, RGBATuple]] + +# the type signature of a sub-parser return value +SubparserReturnValue = Optional[ParserReturnValue] #: A dictionary mapping known color names to rgba tuples. -color_table = { +color_table: Dict[str, RGBATuple] = cast(Dict[str, RGBATuple], { "aliceblue": (0.941, 0.973, 1.000, 1.0), "antiquewhite": (0.980, 0.922, 0.843, 1.0), "aqua": (0.000, 1.000, 1.000, 1.0), @@ -183,13 +191,13 @@ "clear": (0.0, 0.0, 0.0, 0.0), "transparent": (0.0, 0.0, 0.0, 0.0), "none": (0.0, 0.0, 0.0, 0.0), -} +}) # Translation table for stripping extraneous characters out of names. ignored = str.maketrans({' ': None, '-': None, '_': None}) -def _parse_name(text): +def _parse_name(text: str) -> SubparserReturnValue: """ Parse a color name. Parameters @@ -214,7 +222,7 @@ def _parse_name(text): return None -def _parse_hex(text): +def _parse_hex(text: str) -> SubparserReturnValue: """ Parse a hex form of a color. Parameters @@ -249,8 +257,13 @@ def _parse_hex(text): (int(text[i:i+step], 16) for i in range(0, len(text), step)), maximum=maximum, ) - space = 'rgb' if len(channels) == 3 else 'rgba' - return space, channels + if _is_rgb_tuple(channels): + return 'rgb', channels + elif _is_rgba_tuple(channels): + return 'rgba', channels + else: + # shouldn't get here, but makes mypy happy + return None class ColorParseError(ValueError): @@ -258,7 +271,7 @@ class ColorParseError(ValueError): pass -def parse_text(text): +def parse_text(text: str) -> ParserReturnValue: """ Parse a text representation of a color. Parameters @@ -287,7 +300,7 @@ def parse_text(text): ColorParseError If the string cannot be converted to a valid color. """ - result = None + result: SubparserReturnValue = None for parser in _parse_hex, _parse_name: result = parser(text) if result is not None: diff --git a/pyface/util/tests/test_color_helpers.py b/pyface/util/tests/test_color_helpers.py index 09539b832..0bee57d3a 100644 --- a/pyface/util/tests/test_color_helpers.py +++ b/pyface/util/tests/test_color_helpers.py @@ -8,10 +8,16 @@ # # Thanks for using Enthought open source! -from unittest import TestCase +from unittest import TestCase, skipIf + +try: + import numpy as np +except ImportError: + np = None from pyface.util.color_helpers import ( - channels_to_ints, ints_to_channels, is_dark, relative_luminance + channels_to_ints, ints_to_channels, is_dark, relative_luminance, + int_to_color_tuple, sequence_to_rgba_tuple ) @@ -162,3 +168,101 @@ def test_medium_grey(self): rgb = (0.5, 0.5, 0.5) result = is_dark(rgb) self.assertFalse(result) + + +class TestIntToColorTuple(TestCase): + + def test_good(self): + cases = { + 0x000000: (0.0, 0.0, 0.0), + 0xffffff: (1.0, 1.0, 1.0), + 0x663399: (0.4, 0.2, 0.6), + } + for value, result in cases.items(): + with self.subTest(value=value): + self.assertEqual(int_to_color_tuple(value), result) + + def test_bad(self): + cases = [-1, 0x1000000] + for value in cases: + with self.subTest(value=value): + with self.assertRaises(ValueError): + int_to_color_tuple(value) + + +class TestSequenceToRGBATuple(TestCase): + + def test_good(self): + cases = [ + (0.4, 0.2, 0.6, 1.0), + [0.4, 0.2, 0.6, 1.0], + (0x66, 0x33, 0x99, 0xff), + [0x66, 0x33, 0x99, 0xff], + (0.4, 0.2, 0.6), + [0.4, 0.2, 0.6], + (0x66, 0x33, 0x99), + [0x66, 0x33, 0x99], + ] + + for value in cases: + with self.subTest(value=value): + self.assertEqual( + sequence_to_rgba_tuple(value), + (0.4, 0.2, 0.6, 1.0), + ) + + @skipIf(np is None, "NumPy is needed for test") + def test_good_numpy(self): + rgba_float_dtype = np.dtype([ + ('red', "float64"), + ('green', "float64"), + ('blue', "float64"), + ('alpha', "float64"), + ]) + rgba_uint8_dtype = np.dtype([ + ('red', "uint8"), + ('green', "uint8"), + ('blue', "uint8"), + ('alpha', "uint8"), + ]) + rgb_float_dtype = np.dtype([ + ('red', "float64"), + ('green', "float64"), + ('blue', "float64"), + ]) + rgb_uint8_dtype = np.dtype([ + ('red', "uint8"), + ('green', "uint8"), + ('blue', "uint8"), + ]) + + cases = [ + np.array([0.4, 0.2, 0.6, 1.0]), + np.array([(0.4, 0.2, 0.6, 1.0)], dtype=rgba_float_dtype)[0], + np.array([0x66, 0x33, 0x99, 0xff], dtype='uint8'), + np.array([(0x66, 0x33, 0x99, 0xff)], dtype=rgba_uint8_dtype)[0], + np.array([0.4, 0.2, 0.6]), + np.array([(0.4, 0.2, 0.6)], dtype=rgb_float_dtype)[0], + np.array([0x66, 0x33, 0x99], dtype='uint8'), + np.array([(0x66, 0x33, 0x99)], dtype=rgb_uint8_dtype)[0], + ] + + for value in cases: + with self.subTest(value=value): + self.assertEqual( + sequence_to_rgba_tuple(value), + (0.4, 0.2, 0.6, 1.0), + ) + + def test_bad(self): + cases = [ + (0.4, 0.2), + (0.4, 0.2, 0.3, 1.0, 1.0), + (0.0, 1.00001, 0.9, 1.0), + (0.0, -0.00001, 0.9, 1.0), + (0, -1, 250, 255), + ] + for value in cases: + with self.subTest(value=value): + with self.assertRaises(ValueError): + sequence_to_rgba_tuple(value)