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_table import COLOR_TABLE 23from .exceptions import ColorSyntaxError 24from .fancy_repr import FancyYield 25from .input import getch 26from .terminal import ColorSystem, terminal 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 @property 557 def red(self) -> int | str: 558 """Returns the red component of this color.""" 559 560 return self.rgb[0] 561 562 @property 563 def green(self) -> int | str: 564 """Returns the green component of this color.""" 565 566 return self.rgb[1] 567 568 @property 569 def blue(self) -> int | str: 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) -> str: 612 """Returns the red component of this color.""" 613 614 return hex(int(self.value[1:3], base=16)) 615 616 @property 617 def green(self) -> str: 618 """Returns the green component of this color.""" 619 620 return hex(int(self.value[3:5], base=16)) 621 622 @property 623 def blue(self) -> str: 624 """Returns the blue component of this color.""" 625 626 return hex(int(self.value[5:7], base=16)) 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)
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
.
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.
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:
- 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.
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'
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.
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.
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.
Returns this color's perceived luminance (brightness).
From https://stackoverflow.com/a/596243
Returns the perceived "brightness" of a color.
From https://stackoverflow.com/a/56678483
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.
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.
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.
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.
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.
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.
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 @property 558 def red(self) -> int | str: 559 """Returns the red component of this color.""" 560 561 return self.rgb[0] 562 563 @property 564 def green(self) -> int | str: 565 """Returns the green component of this color.""" 566 567 return self.rgb[1] 568 569 @property 570 def blue(self) -> int | str: 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.
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) -> str: 613 """Returns the red component of this color.""" 614 615 return hex(int(self.value[1:3], base=16)) 616 617 @property 618 def green(self) -> str: 619 """Returns the green component of this color.""" 620 621 return hex(int(self.value[3:5], base=16)) 622 623 @property 624 def blue(self) -> str: 625 """Returns the blue component of this color.""" 626 627 return hex(int(self.value[5:7], base=16))
An arbitrary, CSS-like HEX color.