pytermgui.colors

The module containing all of the color-centric features of this library.

This module provides a base class, Color, and a bunch of abstractions over it.

Shoutout to: https://stackoverflow.com/a/33206814, one of the best StackOverflow answers I've ever bumped into.

View Source
"""The module containing all of the color-centric features of this library.

This module provides a base class, `Color`, and a bunch of abstractions over it.

Shoutout to: https://stackoverflow.com/a/33206814, one of the best StackOverflow
answers I've ever bumped into.
"""

from __future__ import annotations

import re
from math import sqrt  # pylint: disable=no-name-in-module
from functools import lru_cache
from typing import TYPE_CHECKING, Type
from dataclasses import dataclass, field

from .exceptions import ColorSyntaxError
from .terminal import terminal, ColorSystem
from .ansi_interface import reset as reset_style

if TYPE_CHECKING:
    # This cyclic won't be relevant while type checking.
    from .parser import StyledText  # pylint: disable=cyclic-import

__all__ = [
    "COLOR_TABLE",
    "XTERM_NAMED_COLORS",
    "NAMED_COLORS",
    "foreground",
    "background",
    "str_to_color",
    "Color",
    "IndexedColor",
    "RGBColor",
    "HEXColor",
]


RE_256 = re.compile(r"^([\d]{1,3})$")
RE_HEX = re.compile(r"(?:#)?([0-9a-fA-F]{6})")
RE_RGB = re.compile(r"(\d{1,3};\d{1,3};\d{1,3})")

# Adapted from https://gist.github.com/MicahElliott/719710
# TODO: Maybe this could be generated dynamically?
#       See https://superuser.com/a/905280
COLOR_TABLE = [
    (0, 0, 0),
    (170, 0, 0),
    (0, 170, 0),
    (170, 85, 0),
    (0, 0, 170),
    (170, 0, 170),
    (0, 170, 170),
    (170, 170, 170),
    (85, 85, 85),
    (255, 85, 85),
    (85, 255, 85),
    (255, 255, 85),
    (85, 85, 255),
    (255, 85, 255),
    (85, 255, 255),
    (255, 255, 255),
    (0, 0, 0),
    (0, 0, 95),
    (0, 0, 135),
    (0, 0, 175),
    (0, 0, 215),
    (0, 0, 255),
    (0, 95, 0),
    (0, 95, 95),
    (0, 95, 135),
    (0, 95, 175),
    (0, 95, 215),
    (0, 95, 255),
    (0, 135, 0),
    (0, 135, 95),
    (0, 135, 135),
    (0, 135, 175),
    (0, 135, 215),
    (0, 135, 255),
    (0, 175, 0),
    (0, 175, 95),
    (0, 175, 135),
    (0, 175, 175),
    (0, 175, 215),
    (0, 175, 255),
    (0, 215, 0),
    (0, 215, 95),
    (0, 215, 135),
    (0, 215, 175),
    (0, 215, 215),
    (0, 215, 255),
    (0, 255, 0),
    (0, 255, 95),
    (0, 255, 135),
    (0, 255, 175),
    (0, 255, 215),
    (0, 255, 255),
    (95, 0, 0),
    (95, 0, 95),
    (95, 0, 135),
    (95, 0, 175),
    (95, 0, 215),
    (95, 0, 255),
    (95, 95, 0),
    (95, 95, 95),
    (95, 95, 135),
    (95, 95, 175),
    (95, 95, 215),
    (95, 95, 255),
    (95, 135, 0),
    (95, 135, 95),
    (95, 135, 135),
    (95, 135, 175),
    (95, 135, 215),
    (95, 135, 255),
    (95, 175, 0),
    (95, 175, 95),
    (95, 175, 135),
    (95, 175, 175),
    (95, 175, 215),
    (95, 175, 255),
    (95, 215, 0),
    (95, 215, 95),
    (95, 215, 135),
    (95, 215, 175),
    (95, 215, 215),
    (95, 215, 255),
    (95, 255, 0),
    (95, 255, 95),
    (95, 255, 135),
    (95, 255, 175),
    (95, 255, 215),
    (95, 255, 255),
    (135, 0, 0),
    (135, 0, 95),
    (135, 0, 135),
    (135, 0, 175),
    (135, 0, 215),
    (135, 0, 255),
    (135, 95, 0),
    (135, 95, 95),
    (135, 95, 135),
    (135, 95, 175),
    (135, 95, 215),
    (135, 95, 255),
    (135, 135, 0),
    (135, 135, 95),
    (135, 135, 135),
    (135, 135, 175),
    (135, 135, 215),
    (135, 135, 255),
    (135, 175, 0),
    (135, 175, 95),
    (135, 175, 135),
    (135, 175, 175),
    (135, 175, 215),
    (135, 175, 255),
    (135, 215, 0),
    (135, 215, 95),
    (135, 215, 135),
    (135, 215, 175),
    (135, 215, 215),
    (135, 215, 255),
    (135, 255, 0),
    (135, 255, 95),
    (135, 255, 135),
    (135, 255, 175),
    (135, 255, 215),
    (135, 255, 255),
    (175, 0, 0),
    (175, 0, 95),
    (175, 0, 135),
    (175, 0, 175),
    (175, 0, 215),
    (175, 0, 255),
    (175, 95, 0),
    (175, 95, 95),
    (175, 95, 135),
    (175, 95, 175),
    (175, 95, 215),
    (175, 95, 255),
    (175, 135, 0),
    (175, 135, 95),
    (175, 135, 135),
    (175, 135, 175),
    (175, 135, 215),
    (175, 135, 255),
    (175, 175, 0),
    (175, 175, 95),
    (175, 175, 135),
    (175, 175, 175),
    (175, 175, 215),
    (175, 175, 255),
    (175, 215, 0),
    (175, 215, 95),
    (175, 215, 135),
    (175, 215, 175),
    (175, 215, 215),
    (175, 215, 255),
    (175, 255, 0),
    (175, 255, 95),
    (175, 255, 135),
    (175, 255, 175),
    (175, 255, 215),
    (175, 255, 255),
    (215, 0, 0),
    (215, 0, 95),
    (215, 0, 135),
    (215, 0, 175),
    (215, 0, 215),
    (215, 0, 255),
    (215, 95, 0),
    (215, 95, 95),
    (215, 95, 135),
    (215, 95, 175),
    (215, 95, 215),
    (215, 95, 255),
    (215, 135, 0),
    (215, 135, 95),
    (215, 135, 135),
    (215, 135, 175),
    (215, 135, 215),
    (215, 135, 255),
    (215, 175, 0),
    (215, 175, 95),
    (215, 175, 135),
    (215, 175, 175),
    (215, 175, 215),
    (215, 175, 255),
    (215, 215, 0),
    (215, 215, 95),
    (215, 215, 135),
    (215, 215, 175),
    (215, 215, 215),
    (215, 215, 255),
    (215, 255, 0),
    (215, 255, 95),
    (215, 255, 135),
    (215, 255, 175),
    (215, 255, 215),
    (215, 255, 255),
    (255, 0, 0),
    (255, 0, 95),
    (255, 0, 135),
    (255, 0, 175),
    (255, 0, 215),
    (255, 0, 255),
    (255, 95, 0),
    (255, 95, 95),
    (255, 95, 135),
    (255, 95, 175),
    (255, 95, 215),
    (255, 95, 255),
    (255, 135, 0),
    (255, 135, 95),
    (255, 135, 135),
    (255, 135, 175),
    (255, 135, 215),
    (255, 135, 255),
    (255, 175, 0),
    (255, 175, 95),
    (255, 175, 135),
    (255, 175, 175),
    (255, 175, 215),
    (255, 175, 255),
    (255, 215, 0),
    (255, 215, 95),
    (255, 215, 135),
    (255, 215, 175),
    (255, 215, 215),
    (255, 215, 255),
    (255, 255, 0),
    (255, 255, 95),
    (255, 255, 135),
    (255, 255, 175),
    (255, 255, 215),
    (255, 255, 255),
    (8, 8, 8),
    (18, 18, 18),
    (28, 28, 28),
    (38, 38, 38),
    (48, 48, 48),
    (58, 58, 58),
    (68, 68, 68),
    (78, 78, 78),
    (88, 88, 88),
    (98, 98, 98),
    (108, 108, 108),
    (118, 118, 118),
    (128, 128, 128),
    (138, 138, 138),
    (148, 148, 148),
    (158, 158, 158),
    (168, 168, 168),
    (178, 178, 178),
    (188, 188, 188),
    (198, 198, 198),
    (208, 208, 208),
    (218, 218, 218),
    (228, 228, 228),
    (238, 238, 238),
]

XTERM_NAMED_COLORS = {
    0: "black",
    1: "red",
    2: "green",
    3: "yellow",
    4: "blue",
    5: "magenta",
    6: "cyan",
    7: "white",
    8: "bright-black",
    9: "bright-red",
    10: "bright-green",
    11: "bright-yellow",
    12: "bright-blue",
    14: "bright-magenta",
    15: "bright-cyan",
    16: "bright-white",
}

NAMED_COLORS = {**{color: str(index) for index, color in XTERM_NAMED_COLORS.items()}}

_COLOR_CACHE: dict[str, Color] = {}
_COLOR_MATCH_CACHE: dict[tuple[float, float, float], Color] = {}


@dataclass
class Color:
    """A terminal color.

    Args:
        value: The data contained within this color.
        background: Whether this color will represent a color.
    """

    value: str
    background: bool = False

    system: ColorSystem = field(init=False)

    _luminance: float | None = field(init=False, default=None, repr=False)
    _brightness: float | None = field(init=False, default=None, repr=False)
    _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False)

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
        """Creates a color from the given RGB, within terminal's colorsystem.

        Args:
            rgb: The RGB value to base the new color off of.
        """

        raise NotImplementedError

    @property
    def sequence(self) -> str:
        """Returns the ANSI sequence representation of the color."""

        raise NotImplementedError

    @property
    def rgb(self) -> tuple[int, int, int]:
        """Returns this color as a tuple of (red, green, blue) values."""

        if self._rgb is None:
            raise NotImplementedError

        return self._rgb

    @property
    def name(self) -> str:
        """Returns the reverse-parseable name of this color."""

        return ("@" if self.background else "") + self.value

    @property
    def luminance(self) -> float:
        """Returns this color's perceived luminance (brightness).

        From https://stackoverflow.com/a/596243
        """

        # Don't do expensive calculations over and over
        if self._luminance is not None:
            return self._luminance

        def _linearize(color: float) -> float:
            """Converts sRGB color to linear value."""

            if color <= 0.04045:
                return color / 12.92

            return ((color + 0.055) / 1.055) ** 2.4

        red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2])

        red /= 255
        green /= 255
        blue /= 255

        red = _linearize(red)
        blue = _linearize(blue)
        green = _linearize(green)

        self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue

        return self._luminance

    @property
    def brightness(self) -> float:
        """Returns the perceived "brightness" of a color.

        From https://stackoverflow.com/a/56678483
        """

        # Don't do expensive calculations over and over
        if self._brightness is not None:
            return self._brightness

        if self.luminance <= (216 / 24389):
            brightness = self.luminance * (24389 / 27)

        else:
            brightness = self.luminance ** (1 / 3) * 116 - 16

        self._brightness = brightness / 100
        return self._brightness

    def __call__(self, text: str, reset: bool = True) -> StyledText:
        """Colors the given string, returning a `pytermgui.parser.StyledText`."""

        # We import this here as toplevel would cause circular imports, and it won't
        # be used until this method is called anyways.
        from .parser import StyledText  # pylint: disable=import-outside-toplevel

        buff = self.sequence + text
        if reset:
            buff += reset_style()

        return StyledText(buff)

    def get_localized(self) -> Color:
        """Creates a terminal-capability local Color instance.

        This method essentially allows for graceful degradation of colors in the
        terminal, a feature that AFAIK is unique to PyTermGUI at the moment of writing.
        """

        system = terminal.colorsystem
        if self.system <= system:
            return self

        colortype = SYSTEM_TO_TYPE[system]

        local = colortype.from_rgb(self.rgb)
        local.background = self.background

        return local


@dataclass
class IndexedColor(Color):
    """A color representing an index into the xterm-256 color palette."""

    def __post_init__(self) -> None:
        """Ensures data validity."""

        if not self.value.isdigit():
            raise ValueError(
                f"IndexedColor value has to be numerical, got {self.value!r}."
            )

        if not 0 <= int(self.value) < 256:
            raise ValueError(
                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
            )

        if int(self.value) <= 16:
            self.system = ColorSystem.STANDARD

        else:
            self.system = ColorSystem.EIGHT_BIT

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
        """Constructs an `IndexedColor` from the closest matching option."""

        if rgb in _COLOR_MATCH_CACHE:
            color = _COLOR_MATCH_CACHE[rgb]

            assert isinstance(color, IndexedColor)
            return color

        # Normalize the color values
        red, green, blue = (x / 255 for x in rgb)

        # Calculate the eight-bit color index
        if terminal.colorsystem is ColorSystem.EIGHT_BIT:
            color_num = 16
            color_num += 36 * round(red * 5.0)
            color_num += 6 * round(green * 5.0)
            color_num += round(blue * 5.0)

            return cls(str(color_num))

        # Find the least-different color in the table
        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
        color = cls(str(index))

        _COLOR_MATCH_CACHE[rgb] = color

        return color

    @property
    def sequence(self) -> str:
        r"""Returns an ANSI sequence representing this color."""

        index = int(self.value)

        if index >= 16:
            return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"

        if index <= 7:
            index += 30

        else:
            index = index + 82

        if self.background:
            index += 10

        return f"\x1b[{index}m"

    @property
    def rgb(self) -> tuple[int, int, int]:
        """Returns an RGB representation of this color."""

        if self._rgb is not None:
            return self._rgb

        index = int(self.value)
        rgb = COLOR_TABLE[index]

        return (rgb[0], rgb[1], rgb[2])


class GreyscaleRampColor(IndexedColor):
    """The color type used for NO_COLOR greyscale ramps.

    This implementation uses the color's perceived brightness as its base.
    """

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> GreyscaleRampColor:
        """Gets a greyscale color based on the given color's luminance."""

        color = cls("0")
        setattr(color, "_rgb", rgb)

        index = int(232 + color.brightness * 23)
        color.value = str(index)

        return color


@dataclass
class RGBColor(Color):
    """An arbitrary RGB color."""

    system = ColorSystem.TRUE

    def __post_init__(self) -> None:
        """Ensures data validity."""

        if self.value.count(";") != 2:
            raise ValueError(
                "Invalid value passed to RGBColor."
                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
            )

        rgb = tuple(int(num) for num in self.value.split(";"))
        self._rgb = rgb[0], rgb[1], rgb[2]

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
        """Returns an `RGBColor` from the given triplet."""

        return cls(";".join(map(str, rgb)))

    @property
    def sequence(self) -> str:
        """Returns the ANSI sequence representing this color."""

        return (
            "\x1b["
            + ("48" if self.background else "38")
            + ";2;"
            + ";".join(str(num) for num in self.rgb)
            + "m"
        )


@dataclass
class HEXColor(RGBColor):
    """An arbitrary, CSS-like HEX color."""

    system = ColorSystem.TRUE

    def __post_init__(self) -> None:
        """Ensures data validity."""

        data = self.value
        if data.startswith("#"):
            data = data[1:]

        indices = (0, 2), (2, 4), (4, 6)
        rgb = []
        for start, end in indices:
            value = data[start:end]
            rgb.append(int(value, base=16))

        self._rgb = rgb[0], rgb[1], rgb[2]

        assert len(self._rgb) == 3


SYSTEM_TO_TYPE: dict[ColorSystem, Type[Color]] = {
    ColorSystem.NO_COLOR: GreyscaleRampColor,
    ColorSystem.STANDARD: IndexedColor,
    ColorSystem.EIGHT_BIT: IndexedColor,
    ColorSystem.TRUE: RGBColor,
}


def _get_color_difference(
    rgb1: tuple[int, int, int], rgb2: tuple[int, int, int]
) -> float:
    """Gets the geometric difference of 2 RGB colors (0-255).

    See https://en.wikipedia.org/wiki/Color_difference's Euclidian section.
    """

    red1, green1, blue1 = rgb1
    red2, green2, blue2 = rgb2

    redmean = (red1 + red2) // 2

    delta_red = red1 - red2
    delta_green = green1 - green2
    delta_blue = blue1 - blue2

    return sqrt(
        (2 + (redmean / 256)) * (delta_red ** 2)
        + 4 * (delta_green ** 2)
        + (2 + (255 - redmean) / 256) * (delta_blue ** 2)
    )


@lru_cache(maxsize=None)
def str_to_color(
    text: str,
    is_background: bool = False,
    localize: bool = True,
    use_cache: bool = False,
) -> Color:
    """Creates a `Color` from the given text.

    Accepted formats:
    - 0-255: `IndexedColor`.
    - 'rrr;ggg;bbb': `RGBColor`.
    - '(#)rrggbb': `HEXColor`. Leading hash is optional.

    You can also add a leading '@' into the string to make the output represent a
    background color, such as `@#123abc`.

    Args:
        text: The string to format from.
        is_background: Whether the output should be forced into a background color.
            Mostly used internally, when set will take precedence over syntax of leading
            '@' symbol.
        localize: Whether `get_localized` should be called on the output color.
        use_cache: Whether caching should be used.
    """

    def _trim_code(code: str) -> str:
        """Trims the given color code."""

        is_background = code.startswith("48;")

        if (code.startswith("38;5;") or code.startswith("48;5;")) or (
            code.startswith("38;2;") or code.startswith("48;2;")
        ):
            code = code[5:]

        if code.endswith("m"):
            code = code[:-1]

        if is_background:
            code = "@" + code

        return code

    text = _trim_code(text)

    if not use_cache:
        str_to_color.cache_clear()

    if text.startswith("@"):
        is_background = True
        text = text[1:]

    if text in NAMED_COLORS:
        return str_to_color(NAMED_COLORS[text], is_background=is_background)

    color: Color

    # This code is not pretty, but having these separate branches for each type
    # should improve the performance by quite a large margin.
    match = RE_256.match(text)
    if match is not None:
        color = IndexedColor(match[0], background=is_background)

        return color.get_localized() if localize else color

    match = RE_HEX.match(text)
    if match is not None:
        color = HEXColor(match[0], background=is_background).get_localized()

        return color.get_localized() if localize else color

    match = RE_RGB.match(text)
    if match is not None:
        color = RGBColor(match[0], background=is_background).get_localized()

        return color.get_localized() if localize else color

    raise ColorSyntaxError(f"Could not convert {text!r} into a `Color`.")


def foreground(text: str, color: str | Color, reset: bool = True) -> str:
    """Sets the foreground color of the given text.

    Note that the given color will be forced into `background = True`.

    Args:
        text: The text to color.
        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
            str formats.
        reset: Whether the return value should include a reset sequence at the end.

    Returns:
        The colored text, including a reset if set.
    """

    if not isinstance(color, Color):
        color = str_to_color(color)

    color.background = False

    return color(text, reset=reset)


def background(text: str, color: str | Color, reset: bool = True) -> str:
    """Sets the background color of the given text.

    Note that the given color will be forced into `background = True`.

    Args:
        text: The text to color.
        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
            str formats.
        reset: Whether the return value should include a reset sequence at the end.

    Returns:
        The colored text, including a reset if set.
    """

    if not isinstance(color, Color):
        color = str_to_color(color)

    color.background = True

    return color(text, reset=reset)
#   COLOR_TABLE = [(0, 0, 0), (170, 0, 0), (0, 170, 0), (170, 85, 0), (0, 0, 170), (170, 0, 170), (0, 170, 170), (170, 170, 170), (85, 85, 85), (255, 85, 85), (85, 255, 85), (255, 255, 85), (85, 85, 255), (255, 85, 255), (85, 255, 255), (255, 255, 255), (0, 0, 0), (0, 0, 95), (0, 0, 135), (0, 0, 175), (0, 0, 215), (0, 0, 255), (0, 95, 0), (0, 95, 95), (0, 95, 135), (0, 95, 175), (0, 95, 215), (0, 95, 255), (0, 135, 0), (0, 135, 95), (0, 135, 135), (0, 135, 175), (0, 135, 215), (0, 135, 255), (0, 175, 0), (0, 175, 95), (0, 175, 135), (0, 175, 175), (0, 175, 215), (0, 175, 255), (0, 215, 0), (0, 215, 95), (0, 215, 135), (0, 215, 175), (0, 215, 215), (0, 215, 255), (0, 255, 0), (0, 255, 95), (0, 255, 135), (0, 255, 175), (0, 255, 215), (0, 255, 255), (95, 0, 0), (95, 0, 95), (95, 0, 135), (95, 0, 175), (95, 0, 215), (95, 0, 255), (95, 95, 0), (95, 95, 95), (95, 95, 135), (95, 95, 175), (95, 95, 215), (95, 95, 255), (95, 135, 0), (95, 135, 95), (95, 135, 135), (95, 135, 175), (95, 135, 215), (95, 135, 255), (95, 175, 0), (95, 175, 95), (95, 175, 135), (95, 175, 175), (95, 175, 215), (95, 175, 255), (95, 215, 0), (95, 215, 95), (95, 215, 135), (95, 215, 175), (95, 215, 215), (95, 215, 255), (95, 255, 0), (95, 255, 95), (95, 255, 135), (95, 255, 175), (95, 255, 215), (95, 255, 255), (135, 0, 0), (135, 0, 95), (135, 0, 135), (135, 0, 175), (135, 0, 215), (135, 0, 255), (135, 95, 0), (135, 95, 95), (135, 95, 135), (135, 95, 175), (135, 95, 215), (135, 95, 255), (135, 135, 0), (135, 135, 95), (135, 135, 135), (135, 135, 175), (135, 135, 215), (135, 135, 255), (135, 175, 0), (135, 175, 95), (135, 175, 135), (135, 175, 175), (135, 175, 215), (135, 175, 255), (135, 215, 0), (135, 215, 95), (135, 215, 135), (135, 215, 175), (135, 215, 215), (135, 215, 255), (135, 255, 0), (135, 255, 95), (135, 255, 135), (135, 255, 175), (135, 255, 215), (135, 255, 255), (175, 0, 0), (175, 0, 95), (175, 0, 135), (175, 0, 175), (175, 0, 215), (175, 0, 255), (175, 95, 0), (175, 95, 95), (175, 95, 135), (175, 95, 175), (175, 95, 215), (175, 95, 255), (175, 135, 0), (175, 135, 95), (175, 135, 135), (175, 135, 175), (175, 135, 215), (175, 135, 255), (175, 175, 0), (175, 175, 95), (175, 175, 135), (175, 175, 175), (175, 175, 215), (175, 175, 255), (175, 215, 0), (175, 215, 95), (175, 215, 135), (175, 215, 175), (175, 215, 215), (175, 215, 255), (175, 255, 0), (175, 255, 95), (175, 255, 135), (175, 255, 175), (175, 255, 215), (175, 255, 255), (215, 0, 0), (215, 0, 95), (215, 0, 135), (215, 0, 175), (215, 0, 215), (215, 0, 255), (215, 95, 0), (215, 95, 95), (215, 95, 135), (215, 95, 175), (215, 95, 215), (215, 95, 255), (215, 135, 0), (215, 135, 95), (215, 135, 135), (215, 135, 175), (215, 135, 215), (215, 135, 255), (215, 175, 0), (215, 175, 95), (215, 175, 135), (215, 175, 175), (215, 175, 215), (215, 175, 255), (215, 215, 0), (215, 215, 95), (215, 215, 135), (215, 215, 175), (215, 215, 215), (215, 215, 255), (215, 255, 0), (215, 255, 95), (215, 255, 135), (215, 255, 175), (215, 255, 215), (215, 255, 255), (255, 0, 0), (255, 0, 95), (255, 0, 135), (255, 0, 175), (255, 0, 215), (255, 0, 255), (255, 95, 0), (255, 95, 95), (255, 95, 135), (255, 95, 175), (255, 95, 215), (255, 95, 255), (255, 135, 0), (255, 135, 95), (255, 135, 135), (255, 135, 175), (255, 135, 215), (255, 135, 255), (255, 175, 0), (255, 175, 95), (255, 175, 135), (255, 175, 175), (255, 175, 215), (255, 175, 255), (255, 215, 0), (255, 215, 95), (255, 215, 135), (255, 215, 175), (255, 215, 215), (255, 215, 255), (255, 255, 0), (255, 255, 95), (255, 255, 135), (255, 255, 175), (255, 255, 215), (255, 255, 255), (8, 8, 8), (18, 18, 18), (28, 28, 28), (38, 38, 38), (48, 48, 48), (58, 58, 58), (68, 68, 68), (78, 78, 78), (88, 88, 88), (98, 98, 98), (108, 108, 108), (118, 118, 118), (128, 128, 128), (138, 138, 138), (148, 148, 148), (158, 158, 158), (168, 168, 168), (178, 178, 178), (188, 188, 188), (198, 198, 198), (208, 208, 208), (218, 218, 218), (228, 228, 228), (238, 238, 238)]
#   XTERM_NAMED_COLORS = {0: 'black', 1: 'red', 2: 'green', 3: 'yellow', 4: 'blue', 5: 'magenta', 6: 'cyan', 7: 'white', 8: 'bright-black', 9: 'bright-red', 10: 'bright-green', 11: 'bright-yellow', 12: 'bright-blue', 14: 'bright-magenta', 15: 'bright-cyan', 16: 'bright-white'}
#   NAMED_COLORS = {'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': '14', 'bright-cyan': '15', 'bright-white': '16'}
#   def foreground( text: str, color: str | pytermgui.colors.Color, reset: bool = True ) -> str:
View Source
def foreground(text: str, color: str | Color, reset: bool = True) -> str:
    """Sets the foreground color of the given text.

    Note that the given color will be forced into `background = True`.

    Args:
        text: The text to color.
        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
            str formats.
        reset: Whether the return value should include a reset sequence at the end.

    Returns:
        The colored text, including a reset if set.
    """

    if not isinstance(color, Color):
        color = str_to_color(color)

    color.background = False

    return color(text, reset=reset)

Sets the foreground color of the given text.

Note that the given color will be forced into background = True.

Args
  • text: The text to color.
  • color: The color to use. See pytermgui.colors.str_to_color for accepted str formats.
  • reset: Whether the return value should include a reset sequence at the end.
Returns

The colored text, including a reset if set.

#   def background( text: str, color: str | pytermgui.colors.Color, reset: bool = True ) -> str:
View Source
def background(text: str, color: str | Color, reset: bool = True) -> str:
    """Sets the background color of the given text.

    Note that the given color will be forced into `background = True`.

    Args:
        text: The text to color.
        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
            str formats.
        reset: Whether the return value should include a reset sequence at the end.

    Returns:
        The colored text, including a reset if set.
    """

    if not isinstance(color, Color):
        color = str_to_color(color)

    color.background = True

    return color(text, reset=reset)

Sets the background color of the given text.

Note that the given color will be forced into background = True.

Args
  • text: The text to color.
  • color: The color to use. See pytermgui.colors.str_to_color for accepted str formats.
  • reset: Whether the return value should include a reset sequence at the end.
Returns

The colored text, including a reset if set.

#  
@lru_cache(maxsize=None)
def str_to_color( text: str, is_background: bool = False, localize: bool = True, use_cache: bool = False ) -> pytermgui.colors.Color:
View Source
@lru_cache(maxsize=None)
def str_to_color(
    text: str,
    is_background: bool = False,
    localize: bool = True,
    use_cache: bool = False,
) -> Color:
    """Creates a `Color` from the given text.

    Accepted formats:
    - 0-255: `IndexedColor`.
    - 'rrr;ggg;bbb': `RGBColor`.
    - '(#)rrggbb': `HEXColor`. Leading hash is optional.

    You can also add a leading '@' into the string to make the output represent a
    background color, such as `@#123abc`.

    Args:
        text: The string to format from.
        is_background: Whether the output should be forced into a background color.
            Mostly used internally, when set will take precedence over syntax of leading
            '@' symbol.
        localize: Whether `get_localized` should be called on the output color.
        use_cache: Whether caching should be used.
    """

    def _trim_code(code: str) -> str:
        """Trims the given color code."""

        is_background = code.startswith("48;")

        if (code.startswith("38;5;") or code.startswith("48;5;")) or (
            code.startswith("38;2;") or code.startswith("48;2;")
        ):
            code = code[5:]

        if code.endswith("m"):
            code = code[:-1]

        if is_background:
            code = "@" + code

        return code

    text = _trim_code(text)

    if not use_cache:
        str_to_color.cache_clear()

    if text.startswith("@"):
        is_background = True
        text = text[1:]

    if text in NAMED_COLORS:
        return str_to_color(NAMED_COLORS[text], is_background=is_background)

    color: Color

    # This code is not pretty, but having these separate branches for each type
    # should improve the performance by quite a large margin.
    match = RE_256.match(text)
    if match is not None:
        color = IndexedColor(match[0], background=is_background)

        return color.get_localized() if localize else color

    match = RE_HEX.match(text)
    if match is not None:
        color = HEXColor(match[0], background=is_background).get_localized()

        return color.get_localized() if localize else color

    match = RE_RGB.match(text)
    if match is not None:
        color = RGBColor(match[0], background=is_background).get_localized()

        return color.get_localized() if localize else color

    raise ColorSyntaxError(f"Could not convert {text!r} into a `Color`.")

Creates a Color from the given text.

Accepted formats:

You can also add a leading '@' into the string to make the output represent a background color, such as @#123abc.

Args
  • text: The string to format from.
  • is_background: Whether the output should be forced into a background color. Mostly used internally, when set will take precedence over syntax of leading '@' symbol.
  • localize: Whether get_localized should be called on the output color.
  • use_cache: Whether caching should be used.
#  
@dataclass
class Color:
View Source
@dataclass
class Color:
    """A terminal color.

    Args:
        value: The data contained within this color.
        background: Whether this color will represent a color.
    """

    value: str
    background: bool = False

    system: ColorSystem = field(init=False)

    _luminance: float | None = field(init=False, default=None, repr=False)
    _brightness: float | None = field(init=False, default=None, repr=False)
    _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False)

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
        """Creates a color from the given RGB, within terminal's colorsystem.

        Args:
            rgb: The RGB value to base the new color off of.
        """

        raise NotImplementedError

    @property
    def sequence(self) -> str:
        """Returns the ANSI sequence representation of the color."""

        raise NotImplementedError

    @property
    def rgb(self) -> tuple[int, int, int]:
        """Returns this color as a tuple of (red, green, blue) values."""

        if self._rgb is None:
            raise NotImplementedError

        return self._rgb

    @property
    def name(self) -> str:
        """Returns the reverse-parseable name of this color."""

        return ("@" if self.background else "") + self.value

    @property
    def luminance(self) -> float:
        """Returns this color's perceived luminance (brightness).

        From https://stackoverflow.com/a/596243
        """

        # Don't do expensive calculations over and over
        if self._luminance is not None:
            return self._luminance

        def _linearize(color: float) -> float:
            """Converts sRGB color to linear value."""

            if color <= 0.04045:
                return color / 12.92

            return ((color + 0.055) / 1.055) ** 2.4

        red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2])

        red /= 255
        green /= 255
        blue /= 255

        red = _linearize(red)
        blue = _linearize(blue)
        green = _linearize(green)

        self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue

        return self._luminance

    @property
    def brightness(self) -> float:
        """Returns the perceived "brightness" of a color.

        From https://stackoverflow.com/a/56678483
        """

        # Don't do expensive calculations over and over
        if self._brightness is not None:
            return self._brightness

        if self.luminance <= (216 / 24389):
            brightness = self.luminance * (24389 / 27)

        else:
            brightness = self.luminance ** (1 / 3) * 116 - 16

        self._brightness = brightness / 100
        return self._brightness

    def __call__(self, text: str, reset: bool = True) -> StyledText:
        """Colors the given string, returning a `pytermgui.parser.StyledText`."""

        # We import this here as toplevel would cause circular imports, and it won't
        # be used until this method is called anyways.
        from .parser import StyledText  # pylint: disable=import-outside-toplevel

        buff = self.sequence + text
        if reset:
            buff += reset_style()

        return StyledText(buff)

    def get_localized(self) -> Color:
        """Creates a terminal-capability local Color instance.

        This method essentially allows for graceful degradation of colors in the
        terminal, a feature that AFAIK is unique to PyTermGUI at the moment of writing.
        """

        system = terminal.colorsystem
        if self.system <= system:
            return self

        colortype = SYSTEM_TO_TYPE[system]

        local = colortype.from_rgb(self.rgb)
        local.background = self.background

        return local

A terminal color.

Args
  • value: The data contained within this color.
  • background: Whether this color will represent a color.
#   Color(value: str, background: bool = False)
#   background: bool = False
#  
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.Color:
View Source
    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
        """Creates a color from the given RGB, within terminal's colorsystem.

        Args:
            rgb: The RGB value to base the new color off of.
        """

        raise NotImplementedError

Creates a color from the given RGB, within terminal's colorsystem.

Args
  • rgb: The RGB value to base the new color off of.
#   sequence: str

Returns the ANSI sequence representation of the color.

#   rgb: tuple[int, int, int]

Returns this color as a tuple of (red, green, blue) values.

#   name: str

Returns the reverse-parseable name of this color.

#   luminance: float

Returns this color's perceived luminance (brightness).

From https://stackoverflow.com/a/596243

#   brightness: float

Returns the perceived "brightness" of a color.

From https://stackoverflow.com/a/56678483

#   def get_localized(self) -> pytermgui.colors.Color:
View Source
    def get_localized(self) -> Color:
        """Creates a terminal-capability local Color instance.

        This method essentially allows for graceful degradation of colors in the
        terminal, a feature that AFAIK is unique to PyTermGUI at the moment of writing.
        """

        system = terminal.colorsystem
        if self.system <= system:
            return self

        colortype = SYSTEM_TO_TYPE[system]

        local = colortype.from_rgb(self.rgb)
        local.background = self.background

        return local

Creates a terminal-capability local Color instance.

This method essentially allows for graceful degradation of colors in the terminal, a feature that AFAIK is unique to PyTermGUI at the moment of writing.

#  
@dataclass
class IndexedColor(Color):
View Source
@dataclass
class IndexedColor(Color):
    """A color representing an index into the xterm-256 color palette."""

    def __post_init__(self) -> None:
        """Ensures data validity."""

        if not self.value.isdigit():
            raise ValueError(
                f"IndexedColor value has to be numerical, got {self.value!r}."
            )

        if not 0 <= int(self.value) < 256:
            raise ValueError(
                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
            )

        if int(self.value) <= 16:
            self.system = ColorSystem.STANDARD

        else:
            self.system = ColorSystem.EIGHT_BIT

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
        """Constructs an `IndexedColor` from the closest matching option."""

        if rgb in _COLOR_MATCH_CACHE:
            color = _COLOR_MATCH_CACHE[rgb]

            assert isinstance(color, IndexedColor)
            return color

        # Normalize the color values
        red, green, blue = (x / 255 for x in rgb)

        # Calculate the eight-bit color index
        if terminal.colorsystem is ColorSystem.EIGHT_BIT:
            color_num = 16
            color_num += 36 * round(red * 5.0)
            color_num += 6 * round(green * 5.0)
            color_num += round(blue * 5.0)

            return cls(str(color_num))

        # Find the least-different color in the table
        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
        color = cls(str(index))

        _COLOR_MATCH_CACHE[rgb] = color

        return color

    @property
    def sequence(self) -> str:
        r"""Returns an ANSI sequence representing this color."""

        index = int(self.value)

        if index >= 16:
            return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"

        if index <= 7:
            index += 30

        else:
            index = index + 82

        if self.background:
            index += 10

        return f"\x1b[{index}m"

    @property
    def rgb(self) -> tuple[int, int, int]:
        """Returns an RGB representation of this color."""

        if self._rgb is not None:
            return self._rgb

        index = int(self.value)
        rgb = COLOR_TABLE[index]

        return (rgb[0], rgb[1], rgb[2])

A color representing an index into the xterm-256 color palette.

#   IndexedColor(value: str, background: bool = False)
#  
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.IndexedColor:
View Source
    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
        """Constructs an `IndexedColor` from the closest matching option."""

        if rgb in _COLOR_MATCH_CACHE:
            color = _COLOR_MATCH_CACHE[rgb]

            assert isinstance(color, IndexedColor)
            return color

        # Normalize the color values
        red, green, blue = (x / 255 for x in rgb)

        # Calculate the eight-bit color index
        if terminal.colorsystem is ColorSystem.EIGHT_BIT:
            color_num = 16
            color_num += 36 * round(red * 5.0)
            color_num += 6 * round(green * 5.0)
            color_num += round(blue * 5.0)

            return cls(str(color_num))

        # Find the least-different color in the table
        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
        color = cls(str(index))

        _COLOR_MATCH_CACHE[rgb] = color

        return color

Constructs an IndexedColor from the closest matching option.

#   sequence: str

Returns an ANSI sequence representing this color.

#   rgb: tuple[int, int, int]

Returns an RGB representation of this color.

#  
@dataclass
class RGBColor(Color):
View Source
@dataclass
class RGBColor(Color):
    """An arbitrary RGB color."""

    system = ColorSystem.TRUE

    def __post_init__(self) -> None:
        """Ensures data validity."""

        if self.value.count(";") != 2:
            raise ValueError(
                "Invalid value passed to RGBColor."
                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
            )

        rgb = tuple(int(num) for num in self.value.split(";"))
        self._rgb = rgb[0], rgb[1], rgb[2]

    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
        """Returns an `RGBColor` from the given triplet."""

        return cls(";".join(map(str, rgb)))

    @property
    def sequence(self) -> str:
        """Returns the ANSI sequence representing this color."""

        return (
            "\x1b["
            + ("48" if self.background else "38")
            + ";2;"
            + ";".join(str(num) for num in self.rgb)
            + "m"
        )

An arbitrary RGB color.

#   RGBColor(value: str, background: bool = False)
#   system: pytermgui.terminal.ColorSystem = <ColorSystem.TRUE: 2>
#  
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.RGBColor:
View Source
    @classmethod
    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
        """Returns an `RGBColor` from the given triplet."""

        return cls(";".join(map(str, rgb)))

Returns an RGBColor from the given triplet.

#   sequence: str

Returns the ANSI sequence representing this color.

#  
@dataclass
class HEXColor(RGBColor):
View Source
@dataclass
class HEXColor(RGBColor):
    """An arbitrary, CSS-like HEX color."""

    system = ColorSystem.TRUE

    def __post_init__(self) -> None:
        """Ensures data validity."""

        data = self.value
        if data.startswith("#"):
            data = data[1:]

        indices = (0, 2), (2, 4), (4, 6)
        rgb = []
        for start, end in indices:
            value = data[start:end]
            rgb.append(int(value, base=16))

        self._rgb = rgb[0], rgb[1], rgb[2]

        assert len(self._rgb) == 3

An arbitrary, CSS-like HEX color.

#   HEXColor(value: str, background: bool = False)
#   system: pytermgui.terminal.ColorSystem = <ColorSystem.TRUE: 2>