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.

  1"""The module containing all of the color-centric features of this library.
  2
  3This module provides a base class, `Color`, and a bunch of abstractions over it.
  4
  5Shoutout to: https://stackoverflow.com/a/33206814, one of the best StackOverflow
  6answers I've ever bumped into.
  7"""
  8
  9# pylint: disable=too-many-instance-attributes
 10
 11
 12from __future__ import annotations
 13
 14import re
 15import sys
 16from dataclasses import dataclass, field
 17from functools import cached_property, lru_cache
 18from math import sqrt  # pylint: disable=no-name-in-module
 19from typing import TYPE_CHECKING, Generator, Literal, Type
 20
 21from .ansi_interface import reset as reset_style
 22from .color_info import COLOR_TABLE, CSS_COLORS
 23from .exceptions import ColorSyntaxError
 24from .input import getch
 25from .terminal import ColorSystem, terminal
 26
 27if TYPE_CHECKING:
 28    from .fancy_repr import FancyYield
 29
 30__all__ = [
 31    "COLOR_TABLE",
 32    "XTERM_NAMED_COLORS",
 33    "NAMED_COLORS",
 34    "clear_color_cache",
 35    "foreground",
 36    "background",
 37    "str_to_color",
 38    "Color",
 39    "IndexedColor",
 40    "StandardColor",
 41    "RGBColor",
 42    "HEXColor",
 43]
 44
 45
 46RE_256 = re.compile(r"^([\d]{1,3})$")
 47RE_HEX = re.compile(r"(?:#)?([0-9a-fA-F]{6})")
 48RE_RGB = re.compile(r"(\d{1,3};\d{1,3};\d{1,3})")
 49
 50RE_PALETTE_REPLY = re.compile(
 51    r"\x1b]((?:10)|(?:11));rgb:([0-9a-f]{4})\/([0-9a-f]{4})\/([0-9a-f]{4})\x1b\\"
 52)
 53
 54PREVIEW_CHAR = "▄▀"
 55
 56XTERM_NAMED_COLORS = {
 57    0: "black",
 58    1: "red",
 59    2: "green",
 60    3: "yellow",
 61    4: "blue",
 62    5: "magenta",
 63    6: "cyan",
 64    7: "white",
 65    8: "bright-black",
 66    9: "bright-red",
 67    10: "bright-green",
 68    11: "bright-yellow",
 69    12: "bright-blue",
 70    14: "bright-magenta",
 71    15: "bright-cyan",
 72    16: "bright-white",
 73}
 74
 75NAMED_COLORS = {
 76    **CSS_COLORS,
 77    **{color: str(index) for index, color in XTERM_NAMED_COLORS.items()},
 78}
 79
 80_COLOR_CACHE: dict[str, Color] = {}
 81_COLOR_MATCH_CACHE: dict[tuple[float, float, float], Color] = {}
 82
 83
 84def clear_color_cache() -> None:
 85    """Clears `_COLOR_CACHE` and `_COLOR_MATCH_CACHE`."""
 86
 87    _COLOR_CACHE.clear()
 88    _COLOR_MATCH_CACHE.clear()
 89
 90
 91def _get_palette_color(color: Literal["10", "11"]) -> Color:
 92    """Gets either the foreground or background color of the current emulator.
 93
 94    Args:
 95        color: The value used for `Ps` in the query. See https://unix.stackexchange.com/a/172674.
 96    """
 97
 98    defaults = {
 99        "10": RGBColor.from_rgb((222, 222, 222)),
100        "11": RGBColor.from_rgb((20, 20, 20)),
101    }
102
103    if not terminal.isatty():
104        return defaults[color]
105
106    sys.stdout.write(f"\x1b]{color};?\007")
107    sys.stdout.flush()
108
109    reply = getch()
110
111    match = RE_PALETTE_REPLY.match(reply)
112    if match is None:
113        return defaults[color]
114
115    _, red, green, blue = match.groups()
116
117    rgb: list[int] = []
118    for part in (red, green, blue):
119        rgb.append(int(part[:2], base=16))
120
121    palette_color = RGBColor.from_rgb(tuple(rgb))  # type: ignore
122    palette_color.background = color == "11"
123
124    return palette_color
125
126
127@dataclass
128class Color:
129    """A terminal color.
130
131    Args:
132        value: The data contained within this color.
133        background: Whether this color will represent a color.
134
135    These colors are all formattable. There are currently 2 'spec' strings:
136    - f"{my_color:tim}" -> Returns self.markup
137    - f"{my_color:seq}" -> Returns self.sequence
138
139    They can thus be used in TIM strings:
140
141        >>> ptg.tim.parse("[{my_color:tim}]Hello")
142        '[<my_color.markup>]Hello'
143
144    And in normal, ANSI coded strings:
145
146        >>> "{my_color:seq}Hello"
147        '<my_color.sequence>Hello'
148    """
149
150    value: str
151    background: bool = False
152
153    system: ColorSystem = field(init=False)
154
155    default_foreground: Color | None = field(default=None, repr=False)
156    default_background: Color | None = field(default=None, repr=False)
157
158    _luminance: float | None = field(init=False, default=None, repr=False)
159    _brightness: float | None = field(init=False, default=None, repr=False)
160    _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False)
161
162    def __format__(self, spec: str) -> str:
163        """Formats the color by the given specification."""
164
165        if spec == "tim":
166            return self.markup
167
168        if spec == "seq":
169            return self.sequence
170
171        return repr(self)
172
173    @classmethod
174    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
175        """Creates a color from the given RGB, within terminal's colorsystem.
176
177        Args:
178            rgb: The RGB value to base the new color off of.
179        """
180
181        raise NotImplementedError
182
183    @property
184    def sequence(self) -> str:
185        """Returns the ANSI sequence representation of the color."""
186
187        raise NotImplementedError
188
189    @cached_property
190    def markup(self) -> str:
191        """Returns the TIM representation of this color."""
192
193        return ("@" if self.background else "") + self.value
194
195    @cached_property
196    def rgb(self) -> tuple[int, int, int]:
197        """Returns this color as a tuple of (red, green, blue) values."""
198
199        if self._rgb is None:
200            raise NotImplementedError
201
202        return self._rgb
203
204    @property
205    def hex(self) -> str:
206        """Returns CSS-like HEX representation of this color."""
207
208        buff = "#"
209        for color in self.rgb:
210            buff += f"{format(color, 'x'):0>2}"
211
212        return buff
213
214    @classmethod
215    def get_default_foreground(cls) -> Color:
216        """Gets the terminal emulator's default foreground color."""
217
218        if cls.default_foreground is not None:
219            return cls.default_foreground
220
221        return _get_palette_color("10")
222
223    @classmethod
224    def get_default_background(cls) -> Color:
225        """Gets the terminal emulator's default foreground color."""
226
227        if cls.default_background is not None:
228            return cls.default_background
229
230        return _get_palette_color("11")
231
232    @property
233    def name(self) -> str:
234        """Returns the reverse-parseable name of this color."""
235
236        return ("@" if self.background else "") + self.value
237
238    @property
239    def luminance(self) -> float:
240        """Returns this color's perceived luminance (brightness).
241
242        From https://stackoverflow.com/a/596243
243        """
244
245        # Don't do expensive calculations over and over
246        if self._luminance is not None:
247            return self._luminance
248
249        def _linearize(color: float) -> float:
250            """Converts sRGB color to linear value."""
251
252            if color <= 0.04045:
253                return color / 12.92
254
255            return ((color + 0.055) / 1.055) ** 2.4
256
257        red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2])
258
259        red /= 255
260        green /= 255
261        blue /= 255
262
263        red = _linearize(red)
264        blue = _linearize(blue)
265        green = _linearize(green)
266
267        self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
268
269        return self._luminance
270
271    @property
272    def brightness(self) -> float:
273        """Returns the perceived "brightness" of a color.
274
275        From https://stackoverflow.com/a/56678483
276        """
277
278        # Don't do expensive calculations over and over
279        if self._brightness is not None:
280            return self._brightness
281
282        if self.luminance <= (216 / 24389):
283            brightness = self.luminance * (24389 / 27)
284
285        else:
286            brightness = self.luminance ** (1 / 3) * 116 - 16
287
288        self._brightness = brightness / 100
289        return self._brightness
290
291    def __call__(self, text: str, reset: bool = True) -> str:
292        """Colors the given string."""
293
294        buff = self.sequence + text
295        if reset:
296            buff += reset_style()
297
298        return buff
299
300    def get_localized(self) -> Color:
301        """Creates a terminal-capability local Color instance.
302
303        This method essentially allows for graceful degradation of colors in the
304        terminal.
305        """
306
307        system = terminal.colorsystem
308        if self.system <= system:
309            return self
310
311        colortype = SYSTEM_TO_TYPE[system]
312
313        local = colortype.from_rgb(self.rgb)
314        local.background = self.background
315
316        return local
317
318
319@dataclass(repr=False)
320class IndexedColor(Color):
321    """A color representing an index into the xterm-256 color palette."""
322
323    system = ColorSystem.EIGHT_BIT
324
325    def __post_init__(self) -> None:
326        """Ensures data validity."""
327
328        if not self.value.isdigit():
329            raise ValueError(
330                f"IndexedColor value has to be numerical, got {self.value!r}."
331            )
332
333        if not 0 <= int(self.value) < 256:
334            raise ValueError(
335                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
336            )
337
338    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
339        """Yields a fancy looking string."""
340
341        yield f"<{type(self).__name__} value: {self.value}, preview: "
342
343        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
344
345        yield ">"
346
347    @classmethod
348    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
349        """Constructs an `IndexedColor` from the closest matching option."""
350
351        if rgb in _COLOR_MATCH_CACHE:
352            color = _COLOR_MATCH_CACHE[rgb]
353
354            assert isinstance(color, IndexedColor)
355            return color
356
357        if terminal.colorsystem == ColorSystem.STANDARD:
358            return StandardColor.from_rgb(rgb)
359
360        # Normalize the color values
361        red, green, blue = (x / 255 for x in rgb)
362
363        # Calculate the eight-bit color index
364        color_num = 16
365        color_num += 36 * round(red * 5.0)
366        color_num += 6 * round(green * 5.0)
367        color_num += round(blue * 5.0)
368
369        color = cls(str(color_num))
370        _COLOR_MATCH_CACHE[rgb] = color
371
372        return color
373
374    @property
375    def sequence(self) -> str:
376        r"""Returns an ANSI sequence representing this color."""
377
378        index = int(self.value)
379
380        return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"
381
382    @cached_property
383    def rgb(self) -> tuple[int, int, int]:
384        """Returns an RGB representation of this color."""
385
386        if self._rgb is not None:
387            return self._rgb
388
389        index = int(self.value)
390        rgb = COLOR_TABLE[index]
391
392        return (rgb[0], rgb[1], rgb[2])
393
394
395class StandardColor(IndexedColor):
396    """A color in the xterm-16 palette."""
397
398    system = ColorSystem.STANDARD
399
400    @property
401    def name(self) -> str:
402        """Returns the markup-compatible name for this color."""
403
404        index = name = int(self.value)
405
406        # Normal colors
407        if 30 <= index <= 47:
408            name -= 30
409
410        elif 90 <= index <= 107:
411            name -= 82
412
413        return ("@" if self.background else "") + str(name)
414
415    @classmethod
416    def from_ansi(cls, code: str) -> StandardColor:
417        """Creates a standard color from the given ANSI code.
418
419        These codes have to be a digit ranging between 31 and 47.
420        """
421
422        if not code.isdigit():
423            raise ColorSyntaxError(
424                f"Standard color codes must be digits, not {code!r}."
425            )
426
427        code_int = int(code)
428
429        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
430            raise ColorSyntaxError(
431                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
432            )
433
434        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
435
436        if is_background:
437            code_int -= 10
438
439        return cls(str(code_int), background=is_background)
440
441    @classmethod
442    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
443        """Creates a color with the closest-matching xterm index, based on rgb.
444
445        Args:
446            rgb: The target color.
447        """
448
449        if rgb in _COLOR_MATCH_CACHE:
450            color = _COLOR_MATCH_CACHE[rgb]
451
452            if color.system is ColorSystem.STANDARD:
453                assert isinstance(color, StandardColor)
454                return color
455
456        # Find the least-different color in the table
457        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
458
459        if index > 7:
460            index += 82
461        else:
462            index += 30
463
464        color = cls(str(index))
465
466        _COLOR_MATCH_CACHE[rgb] = color
467
468        return color
469
470    @property
471    def sequence(self) -> str:
472        r"""Returns an ANSI sequence representing this color."""
473
474        index = int(self.value)
475
476        if self.background:
477            index += 10
478
479        return f"\x1b[{index}m"
480
481    @cached_property
482    def rgb(self) -> tuple[int, int, int]:
483        """Returns an RGB representation of this color."""
484
485        index = int(self.value)
486
487        if 30 <= index <= 47:
488            index -= 30
489
490        elif 90 <= index <= 107:
491            index -= 82
492
493        rgb = COLOR_TABLE[index]
494
495        return (rgb[0], rgb[1], rgb[2])
496
497
498class GreyscaleRampColor(IndexedColor):
499    """The color type used for NO_COLOR greyscale ramps.
500
501    This implementation uses the color's perceived brightness as its base.
502    """
503
504    @classmethod
505    def from_rgb(cls, rgb: tuple[int, int, int]) -> GreyscaleRampColor:
506        """Gets a greyscale color based on the given color's luminance."""
507
508        color = cls("0")
509        setattr(color, "_rgb", rgb)
510
511        index = int(232 + color.brightness * 23)
512        color.value = str(index)
513
514        return color
515
516
517@dataclass(repr=False)
518class RGBColor(Color):
519    """An arbitrary RGB color."""
520
521    system = ColorSystem.TRUE
522
523    def __post_init__(self) -> None:
524        """Ensures data validity."""
525
526        if self.value.count(";") != 2:
527            raise ValueError(
528                "Invalid value passed to RGBColor."
529                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
530            )
531
532        rgb = tuple(int(num) for num in self.value.split(";"))
533        self._rgb = rgb[0], rgb[1], rgb[2]
534
535    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
536        """Yields a fancy looking string."""
537
538        yield (
539            f"<{type(self).__name__} red: {self.red}, green: {self.green},"
540            + f" blue: {self.blue}, preview: "
541        )
542
543        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
544
545        yield ">"
546
547    @classmethod
548    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
549        """Returns an `RGBColor` from the given triplet."""
550
551        return cls(";".join(map(str, rgb)))
552
553    @property
554    def red(self) -> int | str:
555        """Returns the red component of this color."""
556
557        return self.rgb[0]
558
559    @property
560    def green(self) -> int | str:
561        """Returns the green component of this color."""
562
563        return self.rgb[1]
564
565    @property
566    def blue(self) -> int | str:
567        """Returns the blue component of this color."""
568
569        return self.rgb[2]
570
571    @property
572    def sequence(self) -> str:
573        """Returns the ANSI sequence representing this color."""
574
575        return (
576            "\x1b["
577            + ("48" if self.background else "38")
578            + ";2;"
579            + ";".join(str(num) for num in self.rgb)
580            + "m"
581        )
582
583
584@dataclass
585class HEXColor(RGBColor):
586    """An arbitrary, CSS-like HEX color."""
587
588    system = ColorSystem.TRUE
589
590    def __post_init__(self) -> None:
591        """Ensures data validity."""
592
593        data = self.value
594        if data.startswith("#"):
595            data = data[1:]
596
597        indices = (0, 2), (2, 4), (4, 6)
598        rgb = []
599        for start, end in indices:
600            value = data[start:end]
601            rgb.append(int(value, base=16))
602
603        self._rgb = rgb[0], rgb[1], rgb[2]
604
605        assert len(self._rgb) == 3
606
607    @property
608    def red(self) -> str:
609        """Returns the red component of this color."""
610
611        return hex(int(self.value[1:3], base=16))
612
613    @property
614    def green(self) -> str:
615        """Returns the green component of this color."""
616
617        return hex(int(self.value[3:5], base=16))
618
619    @property
620    def blue(self) -> str:
621        """Returns the blue component of this color."""
622
623        return hex(int(self.value[5:7], base=16))
624
625
626SYSTEM_TO_TYPE: dict[ColorSystem, Type[Color]] = {
627    ColorSystem.NO_COLOR: GreyscaleRampColor,
628    ColorSystem.STANDARD: StandardColor,
629    ColorSystem.EIGHT_BIT: IndexedColor,
630    ColorSystem.TRUE: RGBColor,
631}
632
633
634def _get_color_difference(
635    rgb1: tuple[int, int, int], rgb2: tuple[int, int, int]
636) -> float:
637    """Gets the geometric difference of 2 RGB colors (0-255).
638
639    See https://en.wikipedia.org/wiki/Color_difference's Euclidian section.
640    """
641
642    red1, green1, blue1 = rgb1
643    red2, green2, blue2 = rgb2
644
645    redmean = (red1 + red2) // 2
646
647    delta_red = red1 - red2
648    delta_green = green1 - green2
649    delta_blue = blue1 - blue2
650
651    return sqrt(
652        (2 + (redmean / 256)) * (delta_red**2)
653        + 4 * (delta_green**2)
654        + (2 + (255 - redmean) / 256) * (delta_blue**2)
655    )
656
657
658@lru_cache(maxsize=None)
659def str_to_color(
660    text: str,
661    is_background: bool = False,
662    localize: bool = True,
663    use_cache: bool = False,
664) -> Color:
665    """Creates a `Color` from the given text.
666
667    Accepted formats:
668    - 0-255: `IndexedColor`.
669    - 'rrr;ggg;bbb': `RGBColor`.
670    - '(#)rrggbb': `HEXColor`. Leading hash is optional.
671
672    You can also add a leading '@' into the string to make the output represent a
673    background color, such as `@#123abc`.
674
675    Args:
676        text: The string to format from.
677        is_background: Whether the output should be forced into a background color.
678            Mostly used internally, when set will take precedence over syntax of leading
679            '@' symbol.
680        localize: Whether `get_localized` should be called on the output color.
681        use_cache: Whether caching should be used.
682    """
683
684    def _trim_code(code: str) -> str:
685        """Trims the given color code."""
686
687        if not all(char.isdigit() or char in "m;" for char in code):
688            return code
689
690        is_background = code.startswith("48;")
691
692        if (code.startswith("38;5;") or code.startswith("48;5;")) or (
693            code.startswith("38;2;") or code.startswith("48;2;")
694        ):
695            code = code[5:]
696
697        if code.endswith("m"):
698            code = code[:-1]
699
700        if is_background:
701            code = "@" + code
702
703        return code
704
705    text = _trim_code(text)
706
707    if not use_cache:
708        str_to_color.cache_clear()
709
710    if text.startswith("@"):
711        is_background = True
712        text = text[1:]
713
714    if text in NAMED_COLORS:
715        return str_to_color(str(NAMED_COLORS[text]), is_background=is_background)
716
717    color: Color
718
719    # This code is not pretty, but having these separate branches for each type
720    # should improve the performance by quite a large margin.
721    match = RE_256.match(text)
722    if match is not None:
723        # Note: At the moment, all colors become an `IndexedColor`, due to a large
724        #       amount of problems a separated `StandardColor` class caused. Not
725        #       sure if there are any real drawbacks to doing it this way, bar the
726        #       extra characters that 255 colors use up compared to xterm-16.
727        color = IndexedColor(match[0], background=is_background)
728
729        return color.get_localized() if localize else color
730
731    match = RE_HEX.match(text)
732    if match is not None:
733        color = HEXColor(match[0], background=is_background).get_localized()
734
735        return color.get_localized() if localize else color
736
737    match = RE_RGB.match(text)
738    if match is not None:
739        color = RGBColor(match[0], background=is_background).get_localized()
740
741        return color.get_localized() if localize else color
742
743    raise ColorSyntaxError(f"Could not convert {text!r} into a `Color`.")
744
745
746def foreground(text: str, color: str | Color, reset: bool = True) -> str:
747    """Sets the foreground color of the given text.
748
749    Note that the given color will be forced into `background = True`.
750
751    Args:
752        text: The text to color.
753        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
754            str formats.
755        reset: Whether the return value should include a reset sequence at the end.
756
757    Returns:
758        The colored text, including a reset if set.
759    """
760
761    if not isinstance(color, Color):
762        color = str_to_color(color)
763
764    color.background = False
765
766    return color(text, reset=reset)
767
768
769def background(text: str, color: str | Color, reset: bool = True) -> str:
770    """Sets the background color of the given text.
771
772    Note that the given color will be forced into `background = True`.
773
774    Args:
775        text: The text to color.
776        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
777            str formats.
778        reset: Whether the return value should include a reset sequence at the end.
779
780    Returns:
781        The colored text, including a reset if set.
782    """
783
784    if not isinstance(color, Color):
785        color = str_to_color(color)
786
787    color.background = True
788
789    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 = {'aliceblue': '#f0f8ff', 'antiquewhite': '#faebd7', 'aqua': '#00ffff', 'aquamarine': '#7fffd4', 'azure': '#f0ffff', 'beige': '#f5f5dc', 'bisque': '#ffe4c4', 'black': '0', 'blanchedalmond': '#ffebcd', 'blue': '4', 'blueviolet': '#8a2be2', 'brown': '#a52a2a', 'burlywood': '#deb887', 'cadetblue': '#5f9ea0', 'chartreuse': '#7fff00', 'chocolate': '#d2691e', 'coral': '#ff7f50', 'cornflowerblue': '#6495ed', 'cornsilk': '#fff8dc', 'crimson': '#dc143c', 'cyan': '6', 'darkblue': '#00008b', 'darkcyan': '#008b8b', 'darkgoldenrod': '#b8860b', 'darkgray': '#a9a9a9', 'darkgrey': '#a9a9a9', 'darkgreen': '#006400', 'darkkhaki': '#bdb76b', 'darkmagenta': '#8b008b', 'darkolivegreen': '#556b2f', 'darkorange': '#ff8c00', 'darkorchid': '#9932cc', 'darkred': '#8b0000', 'darksalmon': '#e9967a', 'darkseagreen': '#8fbc8f', 'darkslateblue': '#483d8b', 'darkslategray': '#2f4f4f', 'darkslategrey': '#2f4f4f', 'darkturquoise': '#00ced1', 'darkviolet': '#9400d3', 'deeppink': '#ff1493', 'deepskyblue': '#00bfff', 'dimgray': '#696969', 'dimgrey': '#696969', 'dodgerblue': '#1e90ff', 'firebrick': '#b22222', 'floralwhite': '#fffaf0', 'forestgreen': '#228b22', 'fuchsia': '#ff00ff', 'gainsboro': '#dcdcdc', 'ghostwhite': '#f8f8ff', 'gold': '#ffd700', 'goldenrod': '#daa520', 'gray': '#808080', 'grey': '#808080', 'green': '2', 'greenyellow': '#adff2f', 'honeydew': '#f0fff0', 'hotpink': '#ff69b4', 'indianred': '#cd5c5c', 'indigo': '#4b0082', 'ivory': '#fffff0', 'khaki': '#f0e68c', 'lavender': '#e6e6fa', 'lavenderblush': '#fff0f5', 'lawngreen': '#7cfc00', 'lemonchiffon': '#fffacd', 'lightblue': '#add8e6', 'lightcoral': '#f08080', 'lightcyan': '#e0ffff', 'lightgoldenrodyellow': '#fafad2', 'lightgray': '#d3d3d3', 'lightgrey': '#d3d3d3', 'lightgreen': '#90ee90', 'lightpink': '#ffb6c1', 'lightsalmon': '#ffa07a', 'lightseagreen': '#20b2aa', 'lightskyblue': '#87cefa', 'lightslategray': '#778899', 'lightslategrey': '#778899', 'lightsteelblue': '#b0c4de', 'lightyellow': '#ffffe0', 'lime': '#00ff00', 'limegreen': '#32cd32', 'linen': '#faf0e6', 'magenta': '5', 'maroon': '#800000', 'mediumaquamarine': '#66cdaa', 'mediumblue': '#0000cd', 'mediumorchid': '#ba55d3', 'mediumpurple': '#9370db', 'mediumseagreen': '#3cb371', 'mediumslateblue': '#7b68ee', 'mediumspringgreen': '#00fa9a', 'mediumturquoise': '#48d1cc', 'mediumvioletred': '#c71585', 'midnightblue': '#191970', 'mintcream': '#f5fffa', 'mistyrose': '#ffe4e1', 'moccasin': '#ffe4b5', 'navajowhite': '#ffdead', 'navy': '#000080', 'oldlace': '#fdf5e6', 'olive': '#808000', 'olivedrab': '#6b8e23', 'orange': '#ffa500', 'orangered': '#ff4500', 'orchid': '#da70d6', 'palegoldenrod': '#eee8aa', 'palegreen': '#98fb98', 'paleturquoise': '#afeeee', 'palevioletred': '#db7093', 'papayawhip': '#ffefd5', 'peachpuff': '#ffdab9', 'peru': '#cd853f', 'pink': '#ffc0cb', 'plum': '#dda0dd', 'powderblue': '#b0e0e6', 'purple': '#800080', 'red': '1', 'rosybrown': '#bc8f8f', 'royalblue': '#4169e1', 'saddlebrown': '#8b4513', 'salmon': '#fa8072', 'sandybrown': '#f4a460', 'seagreen': '#2e8b57', 'seashell': '#fff5ee', 'sienna': '#a0522d', 'silver': '#c0c0c0', 'skyblue': '#87ceeb', 'slateblue': '#6a5acd', 'slategray': '#708090', 'slategrey': '#708090', 'snow': '#fffafa', 'springgreen': '#00ff7f', 'steelblue': '#4682b4', 'tan': '#d2b48c', 'teal': '#008080', 'thistle': '#d8bfd8', 'tomato': '#ff6347', 'turquoise': '#40e0d0', 'violet': '#ee82ee', 'wheat': '#f5deb3', 'white': '7', 'whitesmoke': '#f5f5f5', 'yellow': '3', 'yellowgreen': '#9acd32', '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 clear_color_cache() -> None:
85def clear_color_cache() -> None:
86    """Clears `_COLOR_CACHE` and `_COLOR_MATCH_CACHE`."""
87
88    _COLOR_CACHE.clear()
89    _COLOR_MATCH_CACHE.clear()

Clears _COLOR_CACHE and _COLOR_MATCH_CACHE.

def foreground( text: str, color: str | pytermgui.colors.Color, reset: bool = True) -> str:
747def foreground(text: str, color: str | Color, reset: bool = True) -> str:
748    """Sets the foreground color of the given text.
749
750    Note that the given color will be forced into `background = True`.
751
752    Args:
753        text: The text to color.
754        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
755            str formats.
756        reset: Whether the return value should include a reset sequence at the end.
757
758    Returns:
759        The colored text, including a reset if set.
760    """
761
762    if not isinstance(color, Color):
763        color = str_to_color(color)
764
765    color.background = False
766
767    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:
770def background(text: str, color: str | Color, reset: bool = True) -> str:
771    """Sets the background color of the given text.
772
773    Note that the given color will be forced into `background = True`.
774
775    Args:
776        text: The text to color.
777        color: The color to use. See `pytermgui.colors.str_to_color` for accepted
778            str formats.
779        reset: Whether the return value should include a reset sequence at the end.
780
781    Returns:
782        The colored text, including a reset if set.
783    """
784
785    if not isinstance(color, Color):
786        color = str_to_color(color)
787
788    color.background = True
789
790    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:
659@lru_cache(maxsize=None)
660def str_to_color(
661    text: str,
662    is_background: bool = False,
663    localize: bool = True,
664    use_cache: bool = False,
665) -> Color:
666    """Creates a `Color` from the given text.
667
668    Accepted formats:
669    - 0-255: `IndexedColor`.
670    - 'rrr;ggg;bbb': `RGBColor`.
671    - '(#)rrggbb': `HEXColor`. Leading hash is optional.
672
673    You can also add a leading '@' into the string to make the output represent a
674    background color, such as `@#123abc`.
675
676    Args:
677        text: The string to format from.
678        is_background: Whether the output should be forced into a background color.
679            Mostly used internally, when set will take precedence over syntax of leading
680            '@' symbol.
681        localize: Whether `get_localized` should be called on the output color.
682        use_cache: Whether caching should be used.
683    """
684
685    def _trim_code(code: str) -> str:
686        """Trims the given color code."""
687
688        if not all(char.isdigit() or char in "m;" for char in code):
689            return code
690
691        is_background = code.startswith("48;")
692
693        if (code.startswith("38;5;") or code.startswith("48;5;")) or (
694            code.startswith("38;2;") or code.startswith("48;2;")
695        ):
696            code = code[5:]
697
698        if code.endswith("m"):
699            code = code[:-1]
700
701        if is_background:
702            code = "@" + code
703
704        return code
705
706    text = _trim_code(text)
707
708    if not use_cache:
709        str_to_color.cache_clear()
710
711    if text.startswith("@"):
712        is_background = True
713        text = text[1:]
714
715    if text in NAMED_COLORS:
716        return str_to_color(str(NAMED_COLORS[text]), is_background=is_background)
717
718    color: Color
719
720    # This code is not pretty, but having these separate branches for each type
721    # should improve the performance by quite a large margin.
722    match = RE_256.match(text)
723    if match is not None:
724        # Note: At the moment, all colors become an `IndexedColor`, due to a large
725        #       amount of problems a separated `StandardColor` class caused. Not
726        #       sure if there are any real drawbacks to doing it this way, bar the
727        #       extra characters that 255 colors use up compared to xterm-16.
728        color = IndexedColor(match[0], background=is_background)
729
730        return color.get_localized() if localize else color
731
732    match = RE_HEX.match(text)
733    if match is not None:
734        color = HEXColor(match[0], background=is_background).get_localized()
735
736        return color.get_localized() if localize else color
737
738    match = RE_RGB.match(text)
739    if match is not None:
740        color = RGBColor(match[0], background=is_background).get_localized()
741
742        return color.get_localized() if localize else color
743
744    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:
128@dataclass
129class Color:
130    """A terminal color.
131
132    Args:
133        value: The data contained within this color.
134        background: Whether this color will represent a color.
135
136    These colors are all formattable. There are currently 2 'spec' strings:
137    - f"{my_color:tim}" -> Returns self.markup
138    - f"{my_color:seq}" -> Returns self.sequence
139
140    They can thus be used in TIM strings:
141
142        >>> ptg.tim.parse("[{my_color:tim}]Hello")
143        '[<my_color.markup>]Hello'
144
145    And in normal, ANSI coded strings:
146
147        >>> "{my_color:seq}Hello"
148        '<my_color.sequence>Hello'
149    """
150
151    value: str
152    background: bool = False
153
154    system: ColorSystem = field(init=False)
155
156    default_foreground: Color | None = field(default=None, repr=False)
157    default_background: Color | None = field(default=None, repr=False)
158
159    _luminance: float | None = field(init=False, default=None, repr=False)
160    _brightness: float | None = field(init=False, default=None, repr=False)
161    _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False)
162
163    def __format__(self, spec: str) -> str:
164        """Formats the color by the given specification."""
165
166        if spec == "tim":
167            return self.markup
168
169        if spec == "seq":
170            return self.sequence
171
172        return repr(self)
173
174    @classmethod
175    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
176        """Creates a color from the given RGB, within terminal's colorsystem.
177
178        Args:
179            rgb: The RGB value to base the new color off of.
180        """
181
182        raise NotImplementedError
183
184    @property
185    def sequence(self) -> str:
186        """Returns the ANSI sequence representation of the color."""
187
188        raise NotImplementedError
189
190    @cached_property
191    def markup(self) -> str:
192        """Returns the TIM representation of this color."""
193
194        return ("@" if self.background else "") + self.value
195
196    @cached_property
197    def rgb(self) -> tuple[int, int, int]:
198        """Returns this color as a tuple of (red, green, blue) values."""
199
200        if self._rgb is None:
201            raise NotImplementedError
202
203        return self._rgb
204
205    @property
206    def hex(self) -> str:
207        """Returns CSS-like HEX representation of this color."""
208
209        buff = "#"
210        for color in self.rgb:
211            buff += f"{format(color, 'x'):0>2}"
212
213        return buff
214
215    @classmethod
216    def get_default_foreground(cls) -> Color:
217        """Gets the terminal emulator's default foreground color."""
218
219        if cls.default_foreground is not None:
220            return cls.default_foreground
221
222        return _get_palette_color("10")
223
224    @classmethod
225    def get_default_background(cls) -> Color:
226        """Gets the terminal emulator's default foreground color."""
227
228        if cls.default_background is not None:
229            return cls.default_background
230
231        return _get_palette_color("11")
232
233    @property
234    def name(self) -> str:
235        """Returns the reverse-parseable name of this color."""
236
237        return ("@" if self.background else "") + self.value
238
239    @property
240    def luminance(self) -> float:
241        """Returns this color's perceived luminance (brightness).
242
243        From https://stackoverflow.com/a/596243
244        """
245
246        # Don't do expensive calculations over and over
247        if self._luminance is not None:
248            return self._luminance
249
250        def _linearize(color: float) -> float:
251            """Converts sRGB color to linear value."""
252
253            if color <= 0.04045:
254                return color / 12.92
255
256            return ((color + 0.055) / 1.055) ** 2.4
257
258        red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2])
259
260        red /= 255
261        green /= 255
262        blue /= 255
263
264        red = _linearize(red)
265        blue = _linearize(blue)
266        green = _linearize(green)
267
268        self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
269
270        return self._luminance
271
272    @property
273    def brightness(self) -> float:
274        """Returns the perceived "brightness" of a color.
275
276        From https://stackoverflow.com/a/56678483
277        """
278
279        # Don't do expensive calculations over and over
280        if self._brightness is not None:
281            return self._brightness
282
283        if self.luminance <= (216 / 24389):
284            brightness = self.luminance * (24389 / 27)
285
286        else:
287            brightness = self.luminance ** (1 / 3) * 116 - 16
288
289        self._brightness = brightness / 100
290        return self._brightness
291
292    def __call__(self, text: str, reset: bool = True) -> str:
293        """Colors the given string."""
294
295        buff = self.sequence + text
296        if reset:
297            buff += reset_style()
298
299        return buff
300
301    def get_localized(self) -> Color:
302        """Creates a terminal-capability local Color instance.
303
304        This method essentially allows for graceful degradation of colors in the
305        terminal.
306        """
307
308        system = terminal.colorsystem
309        if self.system <= system:
310            return self
311
312        colortype = SYSTEM_TO_TYPE[system]
313
314        local = colortype.from_rgb(self.rgb)
315        local.background = self.background
316
317        return local

A terminal color.

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

These colors are all formattable. There are currently 2 'spec' strings:

  • f"{my_color:tim}" -> Returns self.markup
  • f"{my_color:seq}" -> Returns self.sequence
They can thus be used in TIM strings

ptg.tim.parse("[{my_color:tim}]Hello") '[]Hello'

And in normal, ANSI coded strings:

>>> "{my_color:seq}Hello"
'<my_color.sequence>Hello'
Color( value: str, background: bool = False, default_foreground: pytermgui.colors.Color | None = None, default_background: pytermgui.colors.Color | None = None)
background: bool = False
default_foreground: pytermgui.colors.Color | None = None
default_background: pytermgui.colors.Color | None = None
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.Color:
174    @classmethod
175    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
176        """Creates a color from the given RGB, within terminal's colorsystem.
177
178        Args:
179            rgb: The RGB value to base the new color off of.
180        """
181
182        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.

markup: str

Returns the TIM representation of this color.

rgb: tuple[int, int, int]

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

hex: str

Returns CSS-like HEX representation of this color.

@classmethod
def get_default_foreground(cls) -> pytermgui.colors.Color:
215    @classmethod
216    def get_default_foreground(cls) -> Color:
217        """Gets the terminal emulator's default foreground color."""
218
219        if cls.default_foreground is not None:
220            return cls.default_foreground
221
222        return _get_palette_color("10")

Gets the terminal emulator's default foreground color.

@classmethod
def get_default_background(cls) -> pytermgui.colors.Color:
224    @classmethod
225    def get_default_background(cls) -> Color:
226        """Gets the terminal emulator's default foreground color."""
227
228        if cls.default_background is not None:
229            return cls.default_background
230
231        return _get_palette_color("11")

Gets the terminal emulator's default foreground color.

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:
301    def get_localized(self) -> Color:
302        """Creates a terminal-capability local Color instance.
303
304        This method essentially allows for graceful degradation of colors in the
305        terminal.
306        """
307
308        system = terminal.colorsystem
309        if self.system <= system:
310            return self
311
312        colortype = SYSTEM_TO_TYPE[system]
313
314        local = colortype.from_rgb(self.rgb)
315        local.background = self.background
316
317        return local

Creates a terminal-capability local Color instance.

This method essentially allows for graceful degradation of colors in the terminal.

@dataclass(repr=False)
class IndexedColor(Color):
320@dataclass(repr=False)
321class IndexedColor(Color):
322    """A color representing an index into the xterm-256 color palette."""
323
324    system = ColorSystem.EIGHT_BIT
325
326    def __post_init__(self) -> None:
327        """Ensures data validity."""
328
329        if not self.value.isdigit():
330            raise ValueError(
331                f"IndexedColor value has to be numerical, got {self.value!r}."
332            )
333
334        if not 0 <= int(self.value) < 256:
335            raise ValueError(
336                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
337            )
338
339    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
340        """Yields a fancy looking string."""
341
342        yield f"<{type(self).__name__} value: {self.value}, preview: "
343
344        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
345
346        yield ">"
347
348    @classmethod
349    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
350        """Constructs an `IndexedColor` from the closest matching option."""
351
352        if rgb in _COLOR_MATCH_CACHE:
353            color = _COLOR_MATCH_CACHE[rgb]
354
355            assert isinstance(color, IndexedColor)
356            return color
357
358        if terminal.colorsystem == ColorSystem.STANDARD:
359            return StandardColor.from_rgb(rgb)
360
361        # Normalize the color values
362        red, green, blue = (x / 255 for x in rgb)
363
364        # Calculate the eight-bit color index
365        color_num = 16
366        color_num += 36 * round(red * 5.0)
367        color_num += 6 * round(green * 5.0)
368        color_num += round(blue * 5.0)
369
370        color = cls(str(color_num))
371        _COLOR_MATCH_CACHE[rgb] = color
372
373        return color
374
375    @property
376    def sequence(self) -> str:
377        r"""Returns an ANSI sequence representing this color."""
378
379        index = int(self.value)
380
381        return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"
382
383    @cached_property
384    def rgb(self) -> tuple[int, int, int]:
385        """Returns an RGB representation of this color."""
386
387        if self._rgb is not None:
388            return self._rgb
389
390        index = int(self.value)
391        rgb = COLOR_TABLE[index]
392
393        return (rgb[0], rgb[1], rgb[2])

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

IndexedColor( value: str, background: bool = False, default_foreground: pytermgui.colors.Color | None = None, default_background: pytermgui.colors.Color | None = None)
system: pytermgui.terminal.ColorSystem = <ColorSystem.EIGHT_BIT: 1>
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.IndexedColor:
348    @classmethod
349    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
350        """Constructs an `IndexedColor` from the closest matching option."""
351
352        if rgb in _COLOR_MATCH_CACHE:
353            color = _COLOR_MATCH_CACHE[rgb]
354
355            assert isinstance(color, IndexedColor)
356            return color
357
358        if terminal.colorsystem == ColorSystem.STANDARD:
359            return StandardColor.from_rgb(rgb)
360
361        # Normalize the color values
362        red, green, blue = (x / 255 for x in rgb)
363
364        # Calculate the eight-bit color index
365        color_num = 16
366        color_num += 36 * round(red * 5.0)
367        color_num += 6 * round(green * 5.0)
368        color_num += round(blue * 5.0)
369
370        color = cls(str(color_num))
371        _COLOR_MATCH_CACHE[rgb] = color
372
373        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.

class StandardColor(IndexedColor):
396class StandardColor(IndexedColor):
397    """A color in the xterm-16 palette."""
398
399    system = ColorSystem.STANDARD
400
401    @property
402    def name(self) -> str:
403        """Returns the markup-compatible name for this color."""
404
405        index = name = int(self.value)
406
407        # Normal colors
408        if 30 <= index <= 47:
409            name -= 30
410
411        elif 90 <= index <= 107:
412            name -= 82
413
414        return ("@" if self.background else "") + str(name)
415
416    @classmethod
417    def from_ansi(cls, code: str) -> StandardColor:
418        """Creates a standard color from the given ANSI code.
419
420        These codes have to be a digit ranging between 31 and 47.
421        """
422
423        if not code.isdigit():
424            raise ColorSyntaxError(
425                f"Standard color codes must be digits, not {code!r}."
426            )
427
428        code_int = int(code)
429
430        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
431            raise ColorSyntaxError(
432                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
433            )
434
435        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
436
437        if is_background:
438            code_int -= 10
439
440        return cls(str(code_int), background=is_background)
441
442    @classmethod
443    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
444        """Creates a color with the closest-matching xterm index, based on rgb.
445
446        Args:
447            rgb: The target color.
448        """
449
450        if rgb in _COLOR_MATCH_CACHE:
451            color = _COLOR_MATCH_CACHE[rgb]
452
453            if color.system is ColorSystem.STANDARD:
454                assert isinstance(color, StandardColor)
455                return color
456
457        # Find the least-different color in the table
458        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
459
460        if index > 7:
461            index += 82
462        else:
463            index += 30
464
465        color = cls(str(index))
466
467        _COLOR_MATCH_CACHE[rgb] = color
468
469        return color
470
471    @property
472    def sequence(self) -> str:
473        r"""Returns an ANSI sequence representing this color."""
474
475        index = int(self.value)
476
477        if self.background:
478            index += 10
479
480        return f"\x1b[{index}m"
481
482    @cached_property
483    def rgb(self) -> tuple[int, int, int]:
484        """Returns an RGB representation of this color."""
485
486        index = int(self.value)
487
488        if 30 <= index <= 47:
489            index -= 30
490
491        elif 90 <= index <= 107:
492            index -= 82
493
494        rgb = COLOR_TABLE[index]
495
496        return (rgb[0], rgb[1], rgb[2])

A color in the xterm-16 palette.

system: pytermgui.terminal.ColorSystem = <ColorSystem.STANDARD: 0>
name: str

Returns the markup-compatible name for this color.

@classmethod
def from_ansi(cls, code: str) -> pytermgui.colors.StandardColor:
416    @classmethod
417    def from_ansi(cls, code: str) -> StandardColor:
418        """Creates a standard color from the given ANSI code.
419
420        These codes have to be a digit ranging between 31 and 47.
421        """
422
423        if not code.isdigit():
424            raise ColorSyntaxError(
425                f"Standard color codes must be digits, not {code!r}."
426            )
427
428        code_int = int(code)
429
430        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
431            raise ColorSyntaxError(
432                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
433            )
434
435        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
436
437        if is_background:
438            code_int -= 10
439
440        return cls(str(code_int), background=is_background)

Creates a standard color from the given ANSI code.

These codes have to be a digit ranging between 31 and 47.

@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.StandardColor:
442    @classmethod
443    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
444        """Creates a color with the closest-matching xterm index, based on rgb.
445
446        Args:
447            rgb: The target color.
448        """
449
450        if rgb in _COLOR_MATCH_CACHE:
451            color = _COLOR_MATCH_CACHE[rgb]
452
453            if color.system is ColorSystem.STANDARD:
454                assert isinstance(color, StandardColor)
455                return color
456
457        # Find the least-different color in the table
458        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
459
460        if index > 7:
461            index += 82
462        else:
463            index += 30
464
465        color = cls(str(index))
466
467        _COLOR_MATCH_CACHE[rgb] = color
468
469        return color

Creates a color with the closest-matching xterm index, based on rgb.

Args
  • rgb: The target color.
sequence: str

Returns an ANSI sequence representing this color.

rgb: tuple[int, int, int]

Returns an RGB representation of this color.

@dataclass(repr=False)
class RGBColor(Color):
518@dataclass(repr=False)
519class RGBColor(Color):
520    """An arbitrary RGB color."""
521
522    system = ColorSystem.TRUE
523
524    def __post_init__(self) -> None:
525        """Ensures data validity."""
526
527        if self.value.count(";") != 2:
528            raise ValueError(
529                "Invalid value passed to RGBColor."
530                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
531            )
532
533        rgb = tuple(int(num) for num in self.value.split(";"))
534        self._rgb = rgb[0], rgb[1], rgb[2]
535
536    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
537        """Yields a fancy looking string."""
538
539        yield (
540            f"<{type(self).__name__} red: {self.red}, green: {self.green},"
541            + f" blue: {self.blue}, preview: "
542        )
543
544        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
545
546        yield ">"
547
548    @classmethod
549    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
550        """Returns an `RGBColor` from the given triplet."""
551
552        return cls(";".join(map(str, rgb)))
553
554    @property
555    def red(self) -> int | str:
556        """Returns the red component of this color."""
557
558        return self.rgb[0]
559
560    @property
561    def green(self) -> int | str:
562        """Returns the green component of this color."""
563
564        return self.rgb[1]
565
566    @property
567    def blue(self) -> int | str:
568        """Returns the blue component of this color."""
569
570        return self.rgb[2]
571
572    @property
573    def sequence(self) -> str:
574        """Returns the ANSI sequence representing this color."""
575
576        return (
577            "\x1b["
578            + ("48" if self.background else "38")
579            + ";2;"
580            + ";".join(str(num) for num in self.rgb)
581            + "m"
582        )

An arbitrary RGB color.

RGBColor( value: str, background: bool = False, default_foreground: pytermgui.colors.Color | None = None, default_background: pytermgui.colors.Color | None = None)
system: pytermgui.terminal.ColorSystem = <ColorSystem.TRUE: 2>
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> pytermgui.colors.RGBColor:
548    @classmethod
549    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
550        """Returns an `RGBColor` from the given triplet."""
551
552        return cls(";".join(map(str, rgb)))

Returns an RGBColor from the given triplet.

red: int | str

Returns the red component of this color.

green: int | str

Returns the green component of this color.

blue: int | str

Returns the blue component of this color.

sequence: str

Returns the ANSI sequence representing this color.

@dataclass
class HEXColor(RGBColor):
585@dataclass
586class HEXColor(RGBColor):
587    """An arbitrary, CSS-like HEX color."""
588
589    system = ColorSystem.TRUE
590
591    def __post_init__(self) -> None:
592        """Ensures data validity."""
593
594        data = self.value
595        if data.startswith("#"):
596            data = data[1:]
597
598        indices = (0, 2), (2, 4), (4, 6)
599        rgb = []
600        for start, end in indices:
601            value = data[start:end]
602            rgb.append(int(value, base=16))
603
604        self._rgb = rgb[0], rgb[1], rgb[2]
605
606        assert len(self._rgb) == 3
607
608    @property
609    def red(self) -> str:
610        """Returns the red component of this color."""
611
612        return hex(int(self.value[1:3], base=16))
613
614    @property
615    def green(self) -> str:
616        """Returns the green component of this color."""
617
618        return hex(int(self.value[3:5], base=16))
619
620    @property
621    def blue(self) -> str:
622        """Returns the blue component of this color."""
623
624        return hex(int(self.value[5:7], base=16))

An arbitrary, CSS-like HEX color.

HEXColor( value: str, background: bool = False, default_foreground: pytermgui.colors.Color | None = None, default_background: pytermgui.colors.Color | None = None)
system: pytermgui.terminal.ColorSystem = <ColorSystem.TRUE: 2>
red: str

Returns the red component of this color.

green: str

Returns the green component of this color.

blue: str

Returns the blue component of this color.