| 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 rich_repr, Result |
| 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, |
| "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, |
| ) |
| |
| |
| @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 .text import Text |
| from .style import Style |
| |
| 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: |
| 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) |