You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
576 lines
17 KiB
576 lines
17 KiB
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 .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
|
|
|
|
|
|
class ColorType(IntEnum):
|
|
"""Type of color stored in Color class."""
|
|
|
|
DEFAULT = 0
|
|
STANDARD = 1
|
|
EIGHT_BIT = 2
|
|
TRUECOLOR = 3
|
|
WINDOWS = 4
|
|
|
|
|
|
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,
|
|
"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,
|
|
"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,
|
|
"light_slate_grey": 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,
|
|
"medium_purple1": 141,
|
|
"gold3": 178,
|
|
"dark_khaki": 143,
|
|
"navajo_white3": 144,
|
|
"grey69": 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,
|
|
"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,
|
|
"grey3": 232,
|
|
"grey7": 233,
|
|
"grey11": 234,
|
|
"grey15": 235,
|
|
"grey19": 236,
|
|
"grey23": 237,
|
|
"grey27": 238,
|
|
"grey30": 239,
|
|
"grey35": 240,
|
|
"grey39": 241,
|
|
"grey42": 242,
|
|
"grey46": 243,
|
|
"grey50": 244,
|
|
"grey54": 245,
|
|
"grey58": 246,
|
|
"grey62": 247,
|
|
"grey66": 248,
|
|
"grey70": 249,
|
|
"grey74": 250,
|
|
"grey78": 251,
|
|
"grey82": 252,
|
|
"grey85": 253,
|
|
"grey89": 254,
|
|
"grey93": 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,
|
|
)
|
|
|
|
|
|
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 __repr__(self) -> str:
|
|
return (
|
|
f"<color {self.name!r} ({self.type.name.lower()})>"
|
|
if self.number is None
|
|
else f"<color {self.name!r} {self.number} ({self.type.name.lower()})>"
|
|
)
|
|
|
|
def __rich__(self) -> "Text":
|
|
"""Dispays the actual color if Rich printed."""
|
|
from .text import Text
|
|
from .style import Style
|
|
|
|
return Text.assemble(
|
|
f"<color {self.name!r} ({self.type.name.lower()})",
|
|
("⬤", Style(color=self)),
|
|
" >",
|
|
)
|
|
|
|
@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: "TerminalTheme" = None, foreground=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 == ColorType.DEFAULT or self.type == 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
|
|
from . import box
|
|
|
|
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:
|
|
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
|
|
table.add_row(
|
|
color_cell, str(color_number), Text(f'"{name}"'), color.hex, color.rgb
|
|
)
|
|
|
|
console.print(table)
|