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)
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
.
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.
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.
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:
- 0-255:
IndexedColor
. - 'rrr;ggg;bbb':
RGBColor
. - '(#)rrggbb':
HEXColor
. Leading hash is optional.
You can also add a leading '@' into the string to make the output represent a
background color, such as @#123abc
.
Args
- text: The string to format from.
- is_background: Whether the output should be forced into a background color. Mostly used internally, when set will take precedence over syntax of leading '@' symbol.
- localize: Whether
get_localized
should be called on the output color. - use_cache: Whether caching should be used.
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'
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.