From 97cfe7049d30cdaf9ba3debb6fabf9713dbcce57 Mon Sep 17 00:00:00 2001 From: miruka Date: Mon, 16 Nov 2020 11:08:56 -0400 Subject: [PATCH] Add color.py module for theming --- src/backend/color.py | 310 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/backend/color.py diff --git a/src/backend/color.py b/src/backend/color.py new file mode 100644 index 00000000..a66c089c --- /dev/null +++ b/src/backend/color.py @@ -0,0 +1,310 @@ +# Copyright Mirage authors & contributors +# SPDX-License-Identifier: LGPL-3.0-or-later + +"""Provide the `Color` class and functions to easily construct a `Color`.""" + +import builtins +import colorsys +from copy import copy +from dataclasses import InitVar, dataclass, field +from typing import Optional, Tuple, Union + +from hsluv import hex_to_hsluv, hsluv_to_hex, hsluv_to_rgb, rgb_to_hsluv + +from .svg_colors import SVG_COLORS + +HEX_TO_SVG = {v: k for k, v in SVG_COLORS.items()} + +ColorTuple = Tuple[float, float, float, float] + + +@dataclass(repr=False) +class Color: + """A color manipulable in HSLuv, HSL, RGB, hexadecimal and by SVG name. + + The `Color` object constructor accepts hexadecimal string + ("#RGB", "#RRGGBB" or "#RRGGBBAA"), + [CSS/SVG named colors](https://www.december.com/html/spec/colorsvg.html), + or another `Color` to copy. + + Attributes representing the color in HSLuv, HSL, RGB and hexadecimal + formats can be accessed and modified on these `Color` objects. + + The `hsluv()`/`hsluva()`, `hsl()`/`hsla()` and `rgb()`/`rgba()` + functions in this module are provided to create an object by specifying + a color in other formats. + + Copies of objects with modified attributes can be created with the + with the `Color.but()`, `Color.plus()` and `Copy.times()` methods. + + If the `hue` is outside of the normal 0-359 range, the number is + interpreted as `hue % 360`, e.g. `360` is `0`, `460` is `100`, + or `-20` is `340`. + """ + + # The saturation and luv are properties due to the need for a setter + # capping the value between 0-100, as hsluv handles numbers outside + # this range incorrectly. + + color_hex_or_name: InitVar[str] = "black" + hue: float = field(init=False, default=0) + _saturation: float = field(init=False, default=0) + _luv: float = field(init=False, default=0) + alpha: float = field(init=False, default=1) + + def __post_init__(self, color_hex_or_name: Union["Color", str]) -> None: + if isinstance(color_hex_or_name, Color): + hsluva = color_hex_or_name.hsluva + self.hue, self.saturation, self.luv, self.alpha = hsluva + elif color_hex_or_name.startswith("#"): + self.hex = color_hex_or_name + else: + self.name = color_hex_or_name + + # HSLuv + + @property + def hsluva(self) -> ColorTuple: + return (self.hue, self.saturation, self.luv, self.alpha) + + @hsluva.setter + def hsluva(self, value: ColorTuple) -> None: + self.hue, self.saturation, self.luv, self.alpha = value + + @property + def saturation(self) -> float: + return self._saturation + + @saturation.setter + def saturation(self, value: float) -> None: + self._saturation = max(0, min(100, value)) + + @property + def luv(self) -> float: + return self._luv + + @luv.setter + def luv(self, value: float) -> None: + self._luv = max(0, min(100, value)) + + # HSL + + @property + def hsla(self) -> ColorTuple: + r, g, b = (self.red / 255, self.green / 255, self.blue / 255) + h, l, s = colorsys.rgb_to_hls(r, g, b) + return (h * 360, s * 100, l * 100, self.alpha) + + @hsla.setter + def hsla(self, value: ColorTuple) -> None: + h, s, l = (value[0] / 360, value[1] / 100, value[2] / 100) # noqa + r, g, b = colorsys.hls_to_rgb(h, l, s) + self.rgba = (r * 255, g * 255, b * 255, value[3]) + + @property + def light(self) -> float: + return self.hsla[2] + + @light.setter + def light(self, value: float) -> None: + self.hsla = (self.hue, self.saturation, value, self.alpha) + + # RGB + + @property + def rgba(self) -> ColorTuple: + r, g, b = hsluv_to_rgb(self.hsluva) + return r * 255, g * 255, b * 255, self.alpha + + @rgba.setter + def rgba(self, value: ColorTuple) -> None: + r, g, b = (value[0] / 255, value[1] / 255, value[2] / 255) + self.hsluva = rgb_to_hsluv((r, g, b)) + (self.alpha,) + + @property + def red(self) -> float: + return self.rgba[0] + + @red.setter + def red(self, value: float) -> None: + self.rgba = (value, self.green, self.blue, self.alpha) + + @property + def green(self) -> float: + return self.rgba[1] + + @green.setter + def green(self, value: float) -> None: + self.rgba = (self.red, value, self.blue, self.alpha) + + @property + def blue(self) -> float: + return self.rgba[2] + + @blue.setter + def blue(self, value: float) -> None: + self.rgba = (self.red, self.green, value, self.alpha) + + # Hexadecimal + + @property + def hex(self) -> str: + rgb = hsluv_to_hex(self.hsluva) + alpha = builtins.hex(int(self.alpha * 255))[2:] + return f"{rgb}{alpha if self.alpha < 1 else ''}".lower() + + @hex.setter + def hex(self, value: str) -> None: + if len(value) == 4: + template = "#{r}{r}{g}{g}{b}{b}" + value = template.format(r=value[1], g=value[2], b=value[3]) + + alpha = int(value[-2:] if len(value) == 9 else "ff", 16) / 255 + + self.hsluva = hex_to_hsluv(value) + (alpha,) + + # name color + + @property + def name(self) -> Optional[str]: + return HEX_TO_SVG.get(self.hex) + + @name.setter + def name(self, value: str) -> None: + self.hex = SVG_COLORS[value.lower()] + + # Other methods + + def __repr__(self) -> str: + r, g, b = int(self.red), int(self.green), int(self.blue) + h, s, luv = int(self.hue), int(self.saturation), int(self.luv) + l = int(self.light) # noqa + a = self.alpha + block = f"\x1b[38;2;{r};{g};{b}m█████\x1b[0m" + sep = "\x1b[1;33m/\x1b[0m" + end = f" {sep} {self.name}" if self.name else "" + # Need a terminal with true color support to render the block! + return ( + f"{block} hsluva({h}, {s}, {luv}, {a}) {sep} " + f"hsla({h}, {s}, {l}, {a}) {sep} rgba({r}, {g}, {b}, {a}) {sep} " + f"{self.hex}{end}" + ) + + def but( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + hsluva: Optional[ColorTuple] = None, + hsla: Optional[ColorTuple] = None, + rgba: Optional[ColorTuple] = None, + hex: Optional[str] = None, + name: Optional[str] = None, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with overriden attributes. + + Example: + >>> first = Color(100, 50, 50) + >>> second = c.but(hue=20, saturation=100) + >>> second.hsluva + (20, 50, 100, 1) + """ + + new = copy(self) + + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, value) + + return new + + def plus( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with values added to attributes. + + Example: + >>> first = Color(100, 50, 50) + >>> second = c.plus(hue=10, saturation=-20) + >>> second.hsluva + (110, 30, 50, 1) + """ + + new = copy(self) + + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, getattr(self, arg) + value) + + return new + + def times( + self, + hue: Optional[float] = None, + saturation: Optional[float] = None, + luv: Optional[float] = None, + alpha: Optional[float] = None, + *, + light: Optional[float] = None, + red: Optional[float] = None, + green: Optional[float] = None, + blue: Optional[float] = None, + ) -> "Color": + """Return a copy of this `Color` with multiplied attributes. + + Example: + >>> first = Color(100, 50, 50, 0.8) + >>> second = c.times(luv=2, alpha=0.5) + >>> second.hsluva + (100, 50, 100, 0.4) + """ + + new = copy(self) + + for arg, value in locals().items(): + if arg not in ("new", "self") and value is not None: + setattr(new, arg, getattr(self, arg) * value) + + return new + + +def hsluva( + hue: float = 0, saturation: float = 0, luv: float = 0, alpha: float = 1, +) -> Color: + """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSLuv arguments.""" + return Color().but(hue, saturation, luv, alpha) + + +def hsla( + hue: float = 0, saturation: float = 0, light: float = 0, alpha: float = 1, +) -> Color: + """Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSL arguments.""" + return Color().but(hue, saturation, light=light, alpha=alpha) + + +def rgba( + red: float = 0, green: float = 0, blue: float = 0, alpha: float = 1, +) -> Color: + """Return a `Color` from `(0-255, 0-255, 0-255, 0-1)` RGB arguments.""" + return Color().but(red=red, green=green, blue=blue, alpha=alpha) + + +color = Color +hsluv = hsluva +hsl = hsla +rgb = rgba