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 math import sqrt  # pylint: disable=no-name-in-module
 17from dataclasses import dataclass, field
 18from functools import lru_cache, cached_property
 19from typing import TYPE_CHECKING, Type, Literal, Generator
 20
 21from .input import getch
 22from .fancy_repr import FancyYield
 23from .color_table import COLOR_TABLE
 24from .exceptions import ColorSyntaxError
 25from .terminal import terminal, ColorSystem
 26from .ansi_interface import reset as reset_style
 27
 28if TYPE_CHECKING:
 29    # This cyclic won't be relevant while type checking.
 30    from .parser import StyledText  # pylint: disable=cyclic-import
 31
 32__all__ = [
 33    "COLOR_TABLE",
 34    "XTERM_NAMED_COLORS",
 35    "NAMED_COLORS",
 36    "clear_color_cache",
 37    "foreground",
 38    "background",
 39    "str_to_color",
 40    "Color",
 41    "IndexedColor",
 42    "StandardColor",
 43    "RGBColor",
 44    "HEXColor",
 45]
 46
 47
 48RE_256 = re.compile(r"^([\d]{1,3})$")
 49RE_HEX = re.compile(r"(?:#)?([0-9a-fA-F]{6})")
 50RE_RGB = re.compile(r"(\d{1,3};\d{1,3};\d{1,3})")
 51
 52RE_PALETTE_REPLY = re.compile(
 53    r"\x1b]((?:10)|(?:11));rgb:([0-9a-f]{4})\/([0-9a-f]{4})\/([0-9a-f]{4})\x1b\\"
 54)
 55
 56PREVIEW_CHAR = "▄▀"
 57
 58XTERM_NAMED_COLORS = {
 59    0: "black",
 60    1: "red",
 61    2: "green",
 62    3: "yellow",
 63    4: "blue",
 64    5: "magenta",
 65    6: "cyan",
 66    7: "white",
 67    8: "bright-black",
 68    9: "bright-red",
 69    10: "bright-green",
 70    11: "bright-yellow",
 71    12: "bright-blue",
 72    14: "bright-magenta",
 73    15: "bright-cyan",
 74    16: "bright-white",
 75}
 76
 77NAMED_COLORS = {**{color: str(index) for index, color in XTERM_NAMED_COLORS.items()}}
 78
 79_COLOR_CACHE: dict[str, Color] = {}
 80_COLOR_MATCH_CACHE: dict[tuple[float, float, float], Color] = {}
 81
 82
 83def clear_color_cache() -> None:
 84    """Clears `_COLOR_CACHE` and `_COLOR_MATCH_CACHE`."""
 85
 86    _COLOR_CACHE.clear()
 87    _COLOR_MATCH_CACHE.clear()
 88
 89
 90def _get_palette_color(color: Literal["10", "11"]) -> Color:
 91    """Gets either the foreground or background color of the current emulator.
 92
 93    Args:
 94        color: The value used for `Ps` in the query. See https://unix.stackexchange.com/a/172674.
 95    """
 96
 97    defaults = {
 98        "10": RGBColor.from_rgb((222, 222, 222)),
 99        "11": RGBColor.from_rgb((20, 20, 20)),
100    }
101
102    if not terminal.isatty():
103        return defaults[color]
104
105    sys.stdout.write(f"\x1b]{color};?\007")
106    sys.stdout.flush()
107
108    reply = getch()
109
110    match = RE_PALETTE_REPLY.match(reply)
111    if match is None:
112        return defaults[color]
113
114    _, red, green, blue = match.groups()
115
116    rgb: list[int] = []
117    for part in (red, green, blue):
118        rgb.append(int(part[:2], base=16))
119
120    palette_color = RGBColor.from_rgb(tuple(rgb))  # type: ignore
121    palette_color.background = color == "11"
122
123    return palette_color
124
125
126@dataclass
127class Color:
128    """A terminal color.
129
130    Args:
131        value: The data contained within this color.
132        background: Whether this color will represent a color.
133
134    These colors are all formattable. There are currently 2 'spec' strings:
135    - f"{my_color:tim}" -> Returns self.markup
136    - f"{my_color:seq}" -> Returns self.sequence
137
138    They can thus be used in TIM strings:
139
140        >>> ptg.tim.parse("[{my_color:tim}]Hello")
141        '[<my_color.markup>]Hello'
142
143    And in normal, ANSI coded strings:
144
145        >>> "{my_color:seq}Hello"
146        '<my_color.sequence>Hello'
147    """
148
149    value: str
150    background: bool = False
151
152    system: ColorSystem = field(init=False)
153
154    default_foreground: Color | None = field(default=None, repr=False)
155    default_background: Color | None = field(default=None, repr=False)
156
157    _luminance: float | None = field(init=False, default=None, repr=False)
158    _brightness: float | None = field(init=False, default=None, repr=False)
159    _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False)
160
161    def __format__(self, spec: str) -> str:
162        """Formats the color by the given specification."""
163
164        if spec == "tim":
165            return self.markup
166
167        if spec == "seq":
168            return self.sequence
169
170        return repr(self)
171
172    @classmethod
173    def from_rgb(cls, rgb: tuple[int, int, int]) -> Color:
174        """Creates a color from the given RGB, within terminal's colorsystem.
175
176        Args:
177            rgb: The RGB value to base the new color off of.
178        """
179
180        raise NotImplementedError
181
182    @property
183    def sequence(self) -> str:
184        """Returns the ANSI sequence representation of the color."""
185
186        raise NotImplementedError
187
188    @cached_property
189    def markup(self) -> str:
190        """Returns the TIM representation of this color."""
191
192        return ("@" if self.background else "") + self.value
193
194    @cached_property
195    def rgb(self) -> tuple[int, int, int]:
196        """Returns this color as a tuple of (red, green, blue) values."""
197
198        if self._rgb is None:
199            raise NotImplementedError
200
201        return self._rgb
202
203    @property
204    def hex(self) -> str:
205        """Returns CSS-like HEX representation of this color."""
206
207        buff = "#"
208        for color in self.rgb:
209            buff += f"{format(color, 'x'):0>2}"
210
211        return buff
212
213    @classmethod
214    def get_default_foreground(cls) -> Color:
215        """Gets the terminal emulator's default foreground color."""
216
217        if cls.default_foreground is not None:
218            return cls.default_foreground
219
220        return _get_palette_color("10")
221
222    @classmethod
223    def get_default_background(cls) -> Color:
224        """Gets the terminal emulator's default foreground color."""
225
226        if cls.default_background is not None:
227            return cls.default_background
228
229        return _get_palette_color("11")
230
231    @property
232    def name(self) -> str:
233        """Returns the reverse-parseable name of this color."""
234
235        return ("@" if self.background else "") + self.value
236
237    @property
238    def luminance(self) -> float:
239        """Returns this color's perceived luminance (brightness).
240
241        From https://stackoverflow.com/a/596243
242        """
243
244        # Don't do expensive calculations over and over
245        if self._luminance is not None:
246            return self._luminance
247
248        def _linearize(color: float) -> float:
249            """Converts sRGB color to linear value."""
250
251            if color <= 0.04045:
252                return color / 12.92
253
254            return ((color + 0.055) / 1.055) ** 2.4
255
256        red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2])
257
258        red /= 255
259        green /= 255
260        blue /= 255
261
262        red = _linearize(red)
263        blue = _linearize(blue)
264        green = _linearize(green)
265
266        self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
267
268        return self._luminance
269
270    @property
271    def brightness(self) -> float:
272        """Returns the perceived "brightness" of a color.
273
274        From https://stackoverflow.com/a/56678483
275        """
276
277        # Don't do expensive calculations over and over
278        if self._brightness is not None:
279            return self._brightness
280
281        if self.luminance <= (216 / 24389):
282            brightness = self.luminance * (24389 / 27)
283
284        else:
285            brightness = self.luminance ** (1 / 3) * 116 - 16
286
287        self._brightness = brightness / 100
288        return self._brightness
289
290    def __call__(self, text: str, reset: bool = True) -> StyledText:
291        """Colors the given string, returning a `pytermgui.parser.StyledText`."""
292
293        # We import this here as toplevel would cause circular imports, and it won't
294        # be used until this method is called anyways.
295        from .parser import StyledText  # pylint: disable=import-outside-toplevel
296
297        buff = self.sequence + text
298        if reset:
299            buff += reset_style()
300
301        return StyledText(buff)
302
303    def get_localized(self) -> Color:
304        """Creates a terminal-capability local Color instance.
305
306        This method essentially allows for graceful degradation of colors in the
307        terminal.
308        """
309
310        system = terminal.colorsystem
311        if self.system <= system:
312            return self
313
314        colortype = SYSTEM_TO_TYPE[system]
315
316        local = colortype.from_rgb(self.rgb)
317        local.background = self.background
318
319        return local
320
321
322@dataclass(repr=False)
323class IndexedColor(Color):
324    """A color representing an index into the xterm-256 color palette."""
325
326    system = ColorSystem.EIGHT_BIT
327
328    def __post_init__(self) -> None:
329        """Ensures data validity."""
330
331        if not self.value.isdigit():
332            raise ValueError(
333                f"IndexedColor value has to be numerical, got {self.value!r}."
334            )
335
336        if not 0 <= int(self.value) < 256:
337            raise ValueError(
338                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
339            )
340
341    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
342        """Yields a fancy looking string."""
343
344        yield f"<{type(self).__name__} value: {self.value}, preview: "
345
346        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
347
348        yield ">"
349
350    @classmethod
351    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
352        """Constructs an `IndexedColor` from the closest matching option."""
353
354        if rgb in _COLOR_MATCH_CACHE:
355            color = _COLOR_MATCH_CACHE[rgb]
356
357            assert isinstance(color, IndexedColor)
358            return color
359
360        if terminal.colorsystem == ColorSystem.STANDARD:
361            return StandardColor.from_rgb(rgb)
362
363        # Normalize the color values
364        red, green, blue = (x / 255 for x in rgb)
365
366        # Calculate the eight-bit color index
367        color_num = 16
368        color_num += 36 * round(red * 5.0)
369        color_num += 6 * round(green * 5.0)
370        color_num += round(blue * 5.0)
371
372        color = cls(str(color_num))
373        _COLOR_MATCH_CACHE[rgb] = color
374
375        return color
376
377    @property
378    def sequence(self) -> str:
379        r"""Returns an ANSI sequence representing this color."""
380
381        index = int(self.value)
382
383        return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"
384
385    @cached_property
386    def rgb(self) -> tuple[int, int, int]:
387        """Returns an RGB representation of this color."""
388
389        if self._rgb is not None:
390            return self._rgb
391
392        index = int(self.value)
393        rgb = COLOR_TABLE[index]
394
395        return (rgb[0], rgb[1], rgb[2])
396
397
398class StandardColor(IndexedColor):
399    """A color in the xterm-16 palette."""
400
401    system = ColorSystem.STANDARD
402
403    @property
404    def name(self) -> str:
405        """Returns the markup-compatible name for this color."""
406
407        index = name = int(self.value)
408
409        # Normal colors
410        if 30 <= index <= 47:
411            name -= 30
412
413        elif 90 <= index <= 107:
414            name -= 82
415
416        return ("@" if self.background else "") + str(name)
417
418    @classmethod
419    def from_ansi(cls, code: str) -> StandardColor:
420        """Creates a standard color from the given ANSI code.
421
422        These codes have to be a digit ranging between 31 and 47.
423        """
424
425        if not code.isdigit():
426            raise ColorSyntaxError(
427                f"Standard color codes must be digits, not {code!r}."
428            )
429
430        code_int = int(code)
431
432        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
433            raise ColorSyntaxError(
434                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
435            )
436
437        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
438
439        if is_background:
440            code_int -= 10
441
442        return cls(str(code_int), background=is_background)
443
444    @classmethod
445    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
446        """Creates a color with the closest-matching xterm index, based on rgb.
447
448        Args:
449            rgb: The target color.
450        """
451
452        if rgb in _COLOR_MATCH_CACHE:
453            color = _COLOR_MATCH_CACHE[rgb]
454
455            if color.system is ColorSystem.STANDARD:
456                assert isinstance(color, StandardColor)
457                return color
458
459        # Find the least-different color in the table
460        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
461
462        if index > 7:
463            index += 82
464        else:
465            index += 30
466
467        color = cls(str(index))
468
469        _COLOR_MATCH_CACHE[rgb] = color
470
471        return color
472
473    @property
474    def sequence(self) -> str:
475        r"""Returns an ANSI sequence representing this color."""
476
477        index = int(self.value)
478
479        if self.background:
480            index += 10
481
482        return f"\x1b[{index}m"
483
484    @cached_property
485    def rgb(self) -> tuple[int, int, int]:
486        """Returns an RGB representation of this color."""
487
488        index = int(self.value)
489
490        if 30 <= index <= 47:
491            index -= 30
492
493        elif 90 <= index <= 107:
494            index -= 82
495
496        rgb = COLOR_TABLE[index]
497
498        return (rgb[0], rgb[1], rgb[2])
499
500
501class GreyscaleRampColor(IndexedColor):
502    """The color type used for NO_COLOR greyscale ramps.
503
504    This implementation uses the color's perceived brightness as its base.
505    """
506
507    @classmethod
508    def from_rgb(cls, rgb: tuple[int, int, int]) -> GreyscaleRampColor:
509        """Gets a greyscale color based on the given color's luminance."""
510
511        color = cls("0")
512        setattr(color, "_rgb", rgb)
513
514        index = int(232 + color.brightness * 23)
515        color.value = str(index)
516
517        return color
518
519
520@dataclass(repr=False)
521class RGBColor(Color):
522    """An arbitrary RGB color."""
523
524    system = ColorSystem.TRUE
525
526    def __post_init__(self) -> None:
527        """Ensures data validity."""
528
529        if self.value.count(";") != 2:
530            raise ValueError(
531                "Invalid value passed to RGBColor."
532                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
533            )
534
535        rgb = tuple(int(num) for num in self.value.split(";"))
536        self._rgb = rgb[0], rgb[1], rgb[2]
537
538    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
539        """Yields a fancy looking string."""
540
541        yield (
542            f"<{type(self).__name__} red: {self.red}, green: {self.green},"
543            + f" blue: {self.blue}, preview: "
544        )
545
546        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
547
548        yield ">"
549
550    @classmethod
551    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
552        """Returns an `RGBColor` from the given triplet."""
553
554        return cls(";".join(map(str, rgb)))
555
556    @cached_property
557    def red(self) -> int:
558        """Returns the red component of this color."""
559
560        return self.rgb[0]
561
562    @cached_property
563    def green(self) -> int:
564        """Returns the green component of this color."""
565
566        return self.rgb[1]
567
568    @cached_property
569    def blue(self) -> int:
570        """Returns the blue component of this color."""
571
572        return self.rgb[2]
573
574    @property
575    def sequence(self) -> str:
576        """Returns the ANSI sequence representing this color."""
577
578        return (
579            "\x1b["
580            + ("48" if self.background else "38")
581            + ";2;"
582            + ";".join(str(num) for num in self.rgb)
583            + "m"
584        )
585
586
587@dataclass
588class HEXColor(RGBColor):
589    """An arbitrary, CSS-like HEX color."""
590
591    system = ColorSystem.TRUE
592
593    def __post_init__(self) -> None:
594        """Ensures data validity."""
595
596        data = self.value
597        if data.startswith("#"):
598            data = data[1:]
599
600        indices = (0, 2), (2, 4), (4, 6)
601        rgb = []
602        for start, end in indices:
603            value = data[start:end]
604            rgb.append(int(value, base=16))
605
606        self._rgb = rgb[0], rgb[1], rgb[2]
607
608        assert len(self._rgb) == 3
609
610    @property
611    def red(self) -> int:
612        """Returns the red component of this color."""
613
614        return int(self.value[1:3])
615
616    @property
617    def green(self) -> int:
618        """Returns the green component of this color."""
619
620        return int(self.value[3:5])
621
622    @property
623    def blue(self) -> int:
624        """Returns the blue component of this color."""
625
626        return int(self.value[5:7])
627
628
629SYSTEM_TO_TYPE: dict[ColorSystem, Type[Color]] = {
630    ColorSystem.NO_COLOR: GreyscaleRampColor,
631    ColorSystem.STANDARD: StandardColor,
632    ColorSystem.EIGHT_BIT: IndexedColor,
633    ColorSystem.TRUE: RGBColor,
634}
635
636
637def _get_color_difference(
638    rgb1: tuple[int, int, int], rgb2: tuple[int, int, int]
639) -> float:
640    """Gets the geometric difference of 2 RGB colors (0-255).
641
642    See https://en.wikipedia.org/wiki/Color_difference's Euclidian section.
643    """
644
645    red1, green1, blue1 = rgb1
646    red2, green2, blue2 = rgb2
647
648    redmean = (red1 + red2) // 2
649
650    delta_red = red1 - red2
651    delta_green = green1 - green2
652    delta_blue = blue1 - blue2
653
654    return sqrt(
655        (2 + (redmean / 256)) * (delta_red ** 2)
656        + 4 * (delta_green ** 2)
657        + (2 + (255 - redmean) / 256) * (delta_blue ** 2)
658    )
659
660
661@lru_cache(maxsize=None)
662def str_to_color(
663    text: str,
664    is_background: bool = False,
665    localize: bool = True,
666    use_cache: bool = False,
667) -> Color:
668    """Creates a `Color` from the given text.
669
670    Accepted formats:
671    - 0-255: `IndexedColor`.
672    - 'rrr;ggg;bbb': `RGBColor`.
673    - '(#)rrggbb': `HEXColor`. Leading hash is optional.
674
675    You can also add a leading '@' into the string to make the output represent a
676    background color, such as `@#123abc`.
677
678    Args:
679        text: The string to format from.
680        is_background: Whether the output should be forced into a background color.
681            Mostly used internally, when set will take precedence over syntax of leading
682            '@' symbol.
683        localize: Whether `get_localized` should be called on the output color.
684        use_cache: Whether caching should be used.
685    """
686
687    def _trim_code(code: str) -> str:
688        """Trims the given color 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 = {'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 clear_color_cache() -> None:
84def clear_color_cache() -> None:
85    """Clears `_COLOR_CACHE` and `_COLOR_MATCH_CACHE`."""
86
87    _COLOR_CACHE.clear()
88    _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:
662@lru_cache(maxsize=None)
663def str_to_color(
664    text: str,
665    is_background: bool = False,
666    localize: bool = True,
667    use_cache: bool = False,
668) -> Color:
669    """Creates a `Color` from the given text.
670
671    Accepted formats:
672    - 0-255: `IndexedColor`.
673    - 'rrr;ggg;bbb': `RGBColor`.
674    - '(#)rrggbb': `HEXColor`. Leading hash is optional.
675
676    You can also add a leading '@' into the string to make the output represent a
677    background color, such as `@#123abc`.
678
679    Args:
680        text: The string to format from.
681        is_background: Whether the output should be forced into a background color.
682            Mostly used internally, when set will take precedence over syntax of leading
683            '@' symbol.
684        localize: Whether `get_localized` should be called on the output color.
685        use_cache: Whether caching should be used.
686    """
687
688    def _trim_code(code: str) -> str:
689        """Trims the given color 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:
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) -> StyledText:
292        """Colors the given string, returning a `pytermgui.parser.StyledText`."""
293
294        # We import this here as toplevel would cause circular imports, and it won't
295        # be used until this method is called anyways.
296        from .parser import StyledText  # pylint: disable=import-outside-toplevel
297
298        buff = self.sequence + text
299        if reset:
300            buff += reset_style()
301
302        return StyledText(buff)
303
304    def get_localized(self) -> Color:
305        """Creates a terminal-capability local Color instance.
306
307        This method essentially allows for graceful degradation of colors in the
308        terminal.
309        """
310
311        system = terminal.colorsystem
312        if self.system <= system:
313            return self
314
315        colortype = SYSTEM_TO_TYPE[system]
316
317        local = colortype.from_rgb(self.rgb)
318        local.background = self.background
319
320        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:
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

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:
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")

Gets the terminal emulator's default foreground color.

@classmethod
def get_default_background(cls) -> pytermgui.colors.Color:
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")

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:
304    def get_localized(self) -> Color:
305        """Creates a terminal-capability local Color instance.
306
307        This method essentially allows for graceful degradation of colors in the
308        terminal.
309        """
310
311        system = terminal.colorsystem
312        if self.system <= system:
313            return self
314
315        colortype = SYSTEM_TO_TYPE[system]
316
317        local = colortype.from_rgb(self.rgb)
318        local.background = self.background
319
320        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):
323@dataclass(repr=False)
324class IndexedColor(Color):
325    """A color representing an index into the xterm-256 color palette."""
326
327    system = ColorSystem.EIGHT_BIT
328
329    def __post_init__(self) -> None:
330        """Ensures data validity."""
331
332        if not self.value.isdigit():
333            raise ValueError(
334                f"IndexedColor value has to be numerical, got {self.value!r}."
335            )
336
337        if not 0 <= int(self.value) < 256:
338            raise ValueError(
339                f"IndexedColor value has to fit in range 0-255, got {self.value!r}."
340            )
341
342    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
343        """Yields a fancy looking string."""
344
345        yield f"<{type(self).__name__} value: {self.value}, preview: "
346
347        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
348
349        yield ">"
350
351    @classmethod
352    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
353        """Constructs an `IndexedColor` from the closest matching option."""
354
355        if rgb in _COLOR_MATCH_CACHE:
356            color = _COLOR_MATCH_CACHE[rgb]
357
358            assert isinstance(color, IndexedColor)
359            return color
360
361        if terminal.colorsystem == ColorSystem.STANDARD:
362            return StandardColor.from_rgb(rgb)
363
364        # Normalize the color values
365        red, green, blue = (x / 255 for x in rgb)
366
367        # Calculate the eight-bit color index
368        color_num = 16
369        color_num += 36 * round(red * 5.0)
370        color_num += 6 * round(green * 5.0)
371        color_num += round(blue * 5.0)
372
373        color = cls(str(color_num))
374        _COLOR_MATCH_CACHE[rgb] = color
375
376        return color
377
378    @property
379    def sequence(self) -> str:
380        r"""Returns an ANSI sequence representing this color."""
381
382        index = int(self.value)
383
384        return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m"
385
386    @cached_property
387    def rgb(self) -> tuple[int, int, int]:
388        """Returns an RGB representation of this color."""
389
390        if self._rgb is not None:
391            return self._rgb
392
393        index = int(self.value)
394        rgb = COLOR_TABLE[index]
395
396        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:
351    @classmethod
352    def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor:
353        """Constructs an `IndexedColor` from the closest matching option."""
354
355        if rgb in _COLOR_MATCH_CACHE:
356            color = _COLOR_MATCH_CACHE[rgb]
357
358            assert isinstance(color, IndexedColor)
359            return color
360
361        if terminal.colorsystem == ColorSystem.STANDARD:
362            return StandardColor.from_rgb(rgb)
363
364        # Normalize the color values
365        red, green, blue = (x / 255 for x in rgb)
366
367        # Calculate the eight-bit color index
368        color_num = 16
369        color_num += 36 * round(red * 5.0)
370        color_num += 6 * round(green * 5.0)
371        color_num += round(blue * 5.0)
372
373        color = cls(str(color_num))
374        _COLOR_MATCH_CACHE[rgb] = color
375
376        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):
399class StandardColor(IndexedColor):
400    """A color in the xterm-16 palette."""
401
402    system = ColorSystem.STANDARD
403
404    @property
405    def name(self) -> str:
406        """Returns the markup-compatible name for this color."""
407
408        index = name = int(self.value)
409
410        # Normal colors
411        if 30 <= index <= 47:
412            name -= 30
413
414        elif 90 <= index <= 107:
415            name -= 82
416
417        return ("@" if self.background else "") + str(name)
418
419    @classmethod
420    def from_ansi(cls, code: str) -> StandardColor:
421        """Creates a standard color from the given ANSI code.
422
423        These codes have to be a digit ranging between 31 and 47.
424        """
425
426        if not code.isdigit():
427            raise ColorSyntaxError(
428                f"Standard color codes must be digits, not {code!r}."
429            )
430
431        code_int = int(code)
432
433        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
434            raise ColorSyntaxError(
435                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
436            )
437
438        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
439
440        if is_background:
441            code_int -= 10
442
443        return cls(str(code_int), background=is_background)
444
445    @classmethod
446    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
447        """Creates a color with the closest-matching xterm index, based on rgb.
448
449        Args:
450            rgb: The target color.
451        """
452
453        if rgb in _COLOR_MATCH_CACHE:
454            color = _COLOR_MATCH_CACHE[rgb]
455
456            if color.system is ColorSystem.STANDARD:
457                assert isinstance(color, StandardColor)
458                return color
459
460        # Find the least-different color in the table
461        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
462
463        if index > 7:
464            index += 82
465        else:
466            index += 30
467
468        color = cls(str(index))
469
470        _COLOR_MATCH_CACHE[rgb] = color
471
472        return color
473
474    @property
475    def sequence(self) -> str:
476        r"""Returns an ANSI sequence representing this color."""
477
478        index = int(self.value)
479
480        if self.background:
481            index += 10
482
483        return f"\x1b[{index}m"
484
485    @cached_property
486    def rgb(self) -> tuple[int, int, int]:
487        """Returns an RGB representation of this color."""
488
489        index = int(self.value)
490
491        if 30 <= index <= 47:
492            index -= 30
493
494        elif 90 <= index <= 107:
495            index -= 82
496
497        rgb = COLOR_TABLE[index]
498
499        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:
419    @classmethod
420    def from_ansi(cls, code: str) -> StandardColor:
421        """Creates a standard color from the given ANSI code.
422
423        These codes have to be a digit ranging between 31 and 47.
424        """
425
426        if not code.isdigit():
427            raise ColorSyntaxError(
428                f"Standard color codes must be digits, not {code!r}."
429            )
430
431        code_int = int(code)
432
433        if not 30 <= code_int <= 47 and not 90 <= code_int <= 107:
434            raise ColorSyntaxError(
435                f"Standard color codes must be in the range ]30;47[ or ]90;107[, got {code_int!r}."
436            )
437
438        is_background = 40 <= code_int <= 47 or 100 <= code_int <= 107
439
440        if is_background:
441            code_int -= 10
442
443        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:
445    @classmethod
446    def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor:
447        """Creates a color with the closest-matching xterm index, based on rgb.
448
449        Args:
450            rgb: The target color.
451        """
452
453        if rgb in _COLOR_MATCH_CACHE:
454            color = _COLOR_MATCH_CACHE[rgb]
455
456            if color.system is ColorSystem.STANDARD:
457                assert isinstance(color, StandardColor)
458                return color
459
460        # Find the least-different color in the table
461        index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i]))
462
463        if index > 7:
464            index += 82
465        else:
466            index += 30
467
468        color = cls(str(index))
469
470        _COLOR_MATCH_CACHE[rgb] = color
471
472        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):
521@dataclass(repr=False)
522class RGBColor(Color):
523    """An arbitrary RGB color."""
524
525    system = ColorSystem.TRUE
526
527    def __post_init__(self) -> None:
528        """Ensures data validity."""
529
530        if self.value.count(";") != 2:
531            raise ValueError(
532                "Invalid value passed to RGBColor."
533                + f" Format has to be rrr;ggg;bbb, got {self.value!r}."
534            )
535
536        rgb = tuple(int(num) for num in self.value.split(";"))
537        self._rgb = rgb[0], rgb[1], rgb[2]
538
539    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
540        """Yields a fancy looking string."""
541
542        yield (
543            f"<{type(self).__name__} red: {self.red}, green: {self.green},"
544            + f" blue: {self.blue}, preview: "
545        )
546
547        yield {"text": f"{self:seq}{PREVIEW_CHAR}\x1b[0m", "highlight": False}
548
549        yield ">"
550
551    @classmethod
552    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
553        """Returns an `RGBColor` from the given triplet."""
554
555        return cls(";".join(map(str, rgb)))
556
557    @cached_property
558    def red(self) -> int:
559        """Returns the red component of this color."""
560
561        return self.rgb[0]
562
563    @cached_property
564    def green(self) -> int:
565        """Returns the green component of this color."""
566
567        return self.rgb[1]
568
569    @cached_property
570    def blue(self) -> int:
571        """Returns the blue component of this color."""
572
573        return self.rgb[2]
574
575    @property
576    def sequence(self) -> str:
577        """Returns the ANSI sequence representing this color."""
578
579        return (
580            "\x1b["
581            + ("48" if self.background else "38")
582            + ";2;"
583            + ";".join(str(num) for num in self.rgb)
584            + "m"
585        )

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:
551    @classmethod
552    def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor:
553        """Returns an `RGBColor` from the given triplet."""
554
555        return cls(";".join(map(str, rgb)))

Returns an RGBColor from the given triplet.

red: int

Returns the red component of this color.

green: int

Returns the green component of this color.

blue: int

Returns the blue component of this color.

sequence: str

Returns the ANSI sequence representing this color.

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

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: int

Returns the red component of this color.

green: int

Returns the green component of this color.

blue: int

Returns the blue component of this color.