blob: 6bca2da922c59151f42354ea92616faa1c6b37be [file] [log] [blame]
import platform
import re
from colorsys import rgb_to_hls
from enum import IntEnum
from functools import lru_cache
from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple
from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE
from .color_triplet import ColorTriplet
from .repr import Result, rich_repr
from .terminal_theme import DEFAULT_TERMINAL_THEME
if TYPE_CHECKING: # pragma: no cover
from .terminal_theme import TerminalTheme
from .text import Text
WINDOWS = platform.system() == "Windows"
class ColorSystem(IntEnum):
"""One of the 3 color system supported by terminals."""
STANDARD = 1
EIGHT_BIT = 2
TRUECOLOR = 3
WINDOWS = 4
def __repr__(self) -> str:
return f"ColorSystem.{self.name}"
class ColorType(IntEnum):
"""Type of color stored in Color class."""
DEFAULT = 0
STANDARD = 1
EIGHT_BIT = 2
TRUECOLOR = 3
WINDOWS = 4
def __repr__(self) -> str:
return f"ColorType.{self.name}"
ANSI_COLOR_NAMES = {
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
"bright_black": 8,
"bright_red": 9,
"bright_green": 10,
"bright_yellow": 11,
"bright_blue": 12,
"bright_magenta": 13,
"bright_cyan": 14,
"bright_white": 15,
"grey0": 16,
"gray0": 16,
"navy_blue": 17,
"dark_blue": 18,
"blue3": 20,
"blue1": 21,
"dark_green": 22,
"deep_sky_blue4": 25,
"dodger_blue3": 26,
"dodger_blue2": 27,
"green4": 28,
"spring_green4": 29,
"turquoise4": 30,
"deep_sky_blue3": 32,
"dodger_blue1": 33,
"green3": 40,
"spring_green3": 41,
"dark_cyan": 36,
"light_sea_green": 37,
"deep_sky_blue2": 38,
"deep_sky_blue1": 39,
"spring_green2": 47,
"cyan3": 43,
"dark_turquoise": 44,
"turquoise2": 45,
"green1": 46,
"spring_green1": 48,
"medium_spring_green": 49,
"cyan2": 50,
"cyan1": 51,
"dark_red": 88,
"deep_pink4": 125,
"purple4": 55,
"purple3": 56,
"blue_violet": 57,
"orange4": 94,
"grey37": 59,
"gray37": 59,
"medium_purple4": 60,
"slate_blue3": 62,
"royal_blue1": 63,
"chartreuse4": 64,
"dark_sea_green4": 71,
"pale_turquoise4": 66,
"steel_blue": 67,
"steel_blue3": 68,
"cornflower_blue": 69,
"chartreuse3": 76,
"cadet_blue": 73,
"sky_blue3": 74,
"steel_blue1": 81,
"pale_green3": 114,
"sea_green3": 78,
"aquamarine3": 79,
"medium_turquoise": 80,
"chartreuse2": 112,
"sea_green2": 83,
"sea_green1": 85,
"aquamarine1": 122,
"dark_slate_gray2": 87,
"dark_magenta": 91,
"dark_violet": 128,
"purple": 129,
"light_pink4": 95,
"plum4": 96,
"medium_purple3": 98,
"slate_blue1": 99,
"yellow4": 106,
"wheat4": 101,
"grey53": 102,
"gray53": 102,
"light_slate_grey": 103,
"light_slate_gray": 103,
"medium_purple": 104,
"light_slate_blue": 105,
"dark_olive_green3": 149,
"dark_sea_green": 108,
"light_sky_blue3": 110,
"sky_blue2": 111,
"dark_sea_green3": 150,
"dark_slate_gray3": 116,
"sky_blue1": 117,
"chartreuse1": 118,
"light_green": 120,
"pale_green1": 156,
"dark_slate_gray1": 123,
"red3": 160,
"medium_violet_red": 126,
"magenta3": 164,
"dark_orange3": 166,
"indian_red": 167,
"hot_pink3": 168,
"medium_orchid3": 133,
"medium_orchid": 134,
"medium_purple2": 140,
"dark_goldenrod": 136,
"light_salmon3": 173,
"rosy_brown": 138,
"grey63": 139,
"gray63": 139,
"medium_purple1": 141,
"gold3": 178,
"dark_khaki": 143,
"navajo_white3": 144,
"grey69": 145,
"gray69": 145,
"light_steel_blue3": 146,
"light_steel_blue": 147,
"yellow3": 184,
"dark_sea_green2": 157,
"light_cyan3": 152,
"light_sky_blue1": 153,
"green_yellow": 154,
"dark_olive_green2": 155,
"dark_sea_green1": 193,
"pale_turquoise1": 159,
"deep_pink3": 162,
"magenta2": 200,
"hot_pink2": 169,
"orchid": 170,
"medium_orchid1": 207,
"orange3": 172,
"light_pink3": 174,
"pink3": 175,
"plum3": 176,
"violet": 177,
"light_goldenrod3": 179,
"tan": 180,
"misty_rose3": 181,
"thistle3": 182,
"plum2": 183,
"khaki3": 185,
"light_goldenrod2": 222,
"light_yellow3": 187,
"grey84": 188,
"gray84": 188,
"light_steel_blue1": 189,
"yellow2": 190,
"dark_olive_green1": 192,
"honeydew2": 194,
"light_cyan1": 195,
"red1": 196,
"deep_pink2": 197,
"deep_pink1": 199,
"magenta1": 201,
"orange_red1": 202,
"indian_red1": 204,
"hot_pink": 206,
"dark_orange": 208,
"salmon1": 209,
"light_coral": 210,
"pale_violet_red1": 211,
"orchid2": 212,
"orchid1": 213,
"orange1": 214,
"sandy_brown": 215,
"light_salmon1": 216,
"light_pink1": 217,
"pink1": 218,
"plum1": 219,
"gold1": 220,
"navajo_white1": 223,
"misty_rose1": 224,
"thistle1": 225,
"yellow1": 226,
"light_goldenrod1": 227,
"khaki1": 228,
"wheat1": 229,
"cornsilk1": 230,
"grey100": 231,
"gray100": 231,
"grey3": 232,
"gray3": 232,
"grey7": 233,
"gray7": 233,
"grey11": 234,
"gray11": 234,
"grey15": 235,
"gray15": 235,
"grey19": 236,
"gray19": 236,
"grey23": 237,
"gray23": 237,
"grey27": 238,
"gray27": 238,
"grey30": 239,
"gray30": 239,
"grey35": 240,
"gray35": 240,
"grey39": 241,
"gray39": 241,
"grey42": 242,
"gray42": 242,
"grey46": 243,
"gray46": 243,
"grey50": 244,
"gray50": 244,
"grey54": 245,
"gray54": 245,
"grey58": 246,
"gray58": 246,
"grey62": 247,
"gray62": 247,
"grey66": 248,
"gray66": 248,
"grey70": 249,
"gray70": 249,
"grey74": 250,
"gray74": 250,
"grey78": 251,
"gray78": 251,
"grey82": 252,
"gray82": 252,
"grey85": 253,
"gray85": 253,
"grey89": 254,
"gray89": 254,
"grey93": 255,
"gray93": 255,
}
class ColorParseError(Exception):
"""The color could not be parsed."""
RE_COLOR = re.compile(
r"""^
\#([0-9a-f]{6})$|
color\(([0-9]{1,3})\)$|
rgb\(([\d\s,]+)\)$
""",
re.VERBOSE,
)
@rich_repr
class Color(NamedTuple):
"""Terminal color definition."""
name: str
"""The name of the color (typically the input to Color.parse)."""
type: ColorType
"""The type of the color."""
number: Optional[int] = None
"""The color number, if a standard color, or None."""
triplet: Optional[ColorTriplet] = None
"""A triplet of color components, if an RGB color."""
def __rich__(self) -> "Text":
"""Dispays the actual color if Rich printed."""
from .style import Style
from .text import Text
return Text.assemble(
f"<color {self.name!r} ({self.type.name.lower()})",
("⬤", Style(color=self)),
" >",
)
def __rich_repr__(self) -> Result:
yield self.name
yield self.type
yield "number", self.number, None
yield "triplet", self.triplet, None
@property
def system(self) -> ColorSystem:
"""Get the native color system for this color."""
if self.type == ColorType.DEFAULT:
return ColorSystem.STANDARD
return ColorSystem(int(self.type))
@property
def is_system_defined(self) -> bool:
"""Check if the color is ultimately defined by the system."""
return self.system not in (ColorSystem.EIGHT_BIT, ColorSystem.TRUECOLOR)
@property
def is_default(self) -> bool:
"""Check if the color is a default color."""
return self.type == ColorType.DEFAULT
def get_truecolor(
self, theme: Optional["TerminalTheme"] = None, foreground: bool = True
) -> ColorTriplet:
"""Get an equivalent color triplet for this color.
Args:
theme (TerminalTheme, optional): Optional terminal theme, or None to use default. Defaults to None.
foreground (bool, optional): True for a foreground color, or False for background. Defaults to True.
Returns:
ColorTriplet: A color triplet containing RGB components.
"""
if theme is None:
theme = DEFAULT_TERMINAL_THEME
if self.type == ColorType.TRUECOLOR:
assert self.triplet is not None
return self.triplet
elif self.type == ColorType.EIGHT_BIT:
assert self.number is not None
return EIGHT_BIT_PALETTE[self.number]
elif self.type == ColorType.STANDARD:
assert self.number is not None
return theme.ansi_colors[self.number]
elif self.type == ColorType.WINDOWS:
assert self.number is not None
return WINDOWS_PALETTE[self.number]
else: # self.type == ColorType.DEFAULT:
assert self.number is None
return theme.foreground_color if foreground else theme.background_color
@classmethod
def from_ansi(cls, number: int) -> "Color":
"""Create a Color number from it's 8-bit ansi number.
Args:
number (int): A number between 0-255 inclusive.
Returns:
Color: A new Color instance.
"""
return cls(
name=f"color({number})",
type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT),
number=number,
)
@classmethod
def from_triplet(cls, triplet: "ColorTriplet") -> "Color":
"""Create a truecolor RGB color from a triplet of values.
Args:
triplet (ColorTriplet): A color triplet containing red, green and blue components.
Returns:
Color: A new color object.
"""
return cls(name=triplet.hex, type=ColorType.TRUECOLOR, triplet=triplet)
@classmethod
def from_rgb(cls, red: float, green: float, blue: float) -> "Color":
"""Create a truecolor from three color components in the range(0->255).
Args:
red (float): Red component in range 0-255.
green (float): Green component in range 0-255.
blue (float): Blue component in range 0-255.
Returns:
Color: A new color object.
"""
return cls.from_triplet(ColorTriplet(int(red), int(green), int(blue)))
@classmethod
def default(cls) -> "Color":
"""Get a Color instance representing the default color.
Returns:
Color: Default color.
"""
return cls(name="default", type=ColorType.DEFAULT)
@classmethod
@lru_cache(maxsize=1024)
def parse(cls, color: str) -> "Color":
"""Parse a color definition."""
original_color = color
color = color.lower().strip()
if color == "default":
return cls(color, type=ColorType.DEFAULT)
color_number = ANSI_COLOR_NAMES.get(color)
if color_number is not None:
return cls(
color,
type=(ColorType.STANDARD if color_number < 16 else ColorType.EIGHT_BIT),
number=color_number,
)
color_match = RE_COLOR.match(color)
if color_match is None:
raise ColorParseError(f"{original_color!r} is not a valid color")
color_24, color_8, color_rgb = color_match.groups()
if color_24:
triplet = ColorTriplet(
int(color_24[0:2], 16), int(color_24[2:4], 16), int(color_24[4:6], 16)
)
return cls(color, ColorType.TRUECOLOR, triplet=triplet)
elif color_8:
number = int(color_8)
if number > 255:
raise ColorParseError(f"color number must be <= 255 in {color!r}")
return cls(
color,
type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT),
number=number,
)
else: # color_rgb:
components = color_rgb.split(",")
if len(components) != 3:
raise ColorParseError(
f"expected three components in {original_color!r}"
)
red, green, blue = components
triplet = ColorTriplet(int(red), int(green), int(blue))
if not all(component <= 255 for component in triplet):
raise ColorParseError(
f"color components must be <= 255 in {original_color!r}"
)
return cls(color, ColorType.TRUECOLOR, triplet=triplet)
@lru_cache(maxsize=1024)
def get_ansi_codes(self, foreground: bool = True) -> Tuple[str, ...]:
"""Get the ANSI escape codes for this color."""
_type = self.type
if _type == ColorType.DEFAULT:
return ("39" if foreground else "49",)
elif _type == ColorType.WINDOWS:
number = self.number
assert number is not None
fore, back = (30, 40) if number < 8 else (82, 92)
return (str(fore + number if foreground else back + number),)
elif _type == ColorType.STANDARD:
number = self.number
assert number is not None
fore, back = (30, 40) if number < 8 else (82, 92)
return (str(fore + number if foreground else back + number),)
elif _type == ColorType.EIGHT_BIT:
assert self.number is not None
return ("38" if foreground else "48", "5", str(self.number))
else: # self.standard == ColorStandard.TRUECOLOR:
assert self.triplet is not None
red, green, blue = self.triplet
return ("38" if foreground else "48", "2", str(red), str(green), str(blue))
@lru_cache(maxsize=1024)
def downgrade(self, system: ColorSystem) -> "Color":
"""Downgrade a color system to a system with fewer colors."""
if self.type in [ColorType.DEFAULT, system]:
return self
# Convert to 8-bit color from truecolor color
if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.TRUECOLOR:
assert self.triplet is not None
red, green, blue = self.triplet.normalized
_h, l, s = rgb_to_hls(red, green, blue)
# If saturation is under 10% assume it is grayscale
if s < 0.1:
gray = round(l * 25.0)
if gray == 0:
color_number = 16
elif gray == 25:
color_number = 231
else:
color_number = 231 + gray
return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
color_number = (
16 + 36 * round(red * 5.0) + 6 * round(green * 5.0) + round(blue * 5.0)
)
return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
# Convert to standard from truecolor or 8-bit
elif system == ColorSystem.STANDARD:
if self.system == ColorSystem.TRUECOLOR:
assert self.triplet is not None
triplet = self.triplet
else: # self.system == ColorSystem.EIGHT_BIT
assert self.number is not None
triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number])
color_number = STANDARD_PALETTE.match(triplet)
return Color(self.name, ColorType.STANDARD, number=color_number)
elif system == ColorSystem.WINDOWS:
if self.system == ColorSystem.TRUECOLOR:
assert self.triplet is not None
triplet = self.triplet
else: # self.system == ColorSystem.EIGHT_BIT
assert self.number is not None
if self.number < 16:
return Color(self.name, ColorType.WINDOWS, number=self.number)
triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number])
color_number = WINDOWS_PALETTE.match(triplet)
return Color(self.name, ColorType.WINDOWS, number=color_number)
return self
def parse_rgb_hex(hex_color: str) -> ColorTriplet:
"""Parse six hex characters in to RGB triplet."""
assert len(hex_color) == 6, "must be 6 characters"
color = ColorTriplet(
int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
)
return color
def blend_rgb(
color1: ColorTriplet, color2: ColorTriplet, cross_fade: float = 0.5
) -> ColorTriplet:
"""Blend one RGB color in to another."""
r1, g1, b1 = color1
r2, g2, b2 = color2
new_color = ColorTriplet(
int(r1 + (r2 - r1) * cross_fade),
int(g1 + (g2 - g1) * cross_fade),
int(b1 + (b2 - b1) * cross_fade),
)
return new_color
if __name__ == "__main__": # pragma: no cover
from .console import Console
from .table import Table
from .text import Text
console = Console()
table = Table(show_footer=False, show_edge=True)
table.add_column("Color", width=10, overflow="ellipsis")
table.add_column("Number", justify="right", style="yellow")
table.add_column("Name", style="green")
table.add_column("Hex", style="blue")
table.add_column("RGB", style="magenta")
colors = sorted((v, k) for k, v in ANSI_COLOR_NAMES.items())
for color_number, name in colors:
if "grey" in name:
continue
color_cell = Text(" " * 10, style=f"on {name}")
if color_number < 16:
table.add_row(color_cell, f"{color_number}", Text(f'"{name}"'))
else:
color = EIGHT_BIT_PALETTE[color_number] # type: ignore[has-type]
table.add_row(
color_cell, str(color_number), Text(f'"{name}"'), color.hex, color.rgb
)
console.print(table)