pytermgui.colors
The module containing all of the color-centric features of this library.
This module provides a base class, Color
, and a bunch of abstractions over it.
Shoutout to: https://stackoverflow.com/a/33206814, one of the best StackOverflow answers I've ever bumped into.
View Source
0"""The module containing all of the color-centric features of this library. 1 2This module provides a base class, `Color`, and a bunch of abstractions over it. 3 4Shoutout to: https://stackoverflow.com/a/33206814, one of the best StackOverflow 5answers I've ever bumped into. 6""" 7 8# pylint: disable=too-many-instance-attributes 9 10 11from __future__ import annotations 12 13import re 14import sys 15from math import sqrt # pylint: disable=no-name-in-module 16from dataclasses import dataclass, field 17from typing import TYPE_CHECKING, Type, Literal 18from functools import lru_cache, cached_property 19 20from .input import getch 21from .exceptions import ColorSyntaxError 22from .terminal import terminal, ColorSystem 23from .ansi_interface import reset as reset_style 24 25if TYPE_CHECKING: 26 # This cyclic won't be relevant while type checking. 27 from .parser import StyledText # pylint: disable=cyclic-import 28 29__all__ = [ 30 "COLOR_TABLE", 31 "XTERM_NAMED_COLORS", 32 "NAMED_COLORS", 33 "clear_color_cache", 34 "foreground", 35 "background", 36 "str_to_color", 37 "Color", 38 "IndexedColor", 39 "RGBColor", 40 "HEXColor", 41] 42 43 44RE_256 = re.compile(r"^([\d]{1,3})$") 45RE_HEX = re.compile(r"(?:#)?([0-9a-fA-F]{6})") 46RE_RGB = re.compile(r"(\d{1,3};\d{1,3};\d{1,3})") 47 48RE_PALETTE_REPLY = re.compile( 49 r"\x1b]((?:10)|(?:11));rgb:([0-9a-f]{4})\/([0-9a-f]{4})\/([0-9a-f]{4})\x1b\\" 50) 51 52# Adapted from https://gist.github.com/MicahElliott/719710 53# TODO: Maybe this could be generated dynamically? 54# See https://superuser.com/a/905280 55COLOR_TABLE = [ 56 (0, 0, 0), 57 (170, 0, 0), 58 (0, 170, 0), 59 (170, 85, 0), 60 (0, 0, 170), 61 (170, 0, 170), 62 (0, 170, 170), 63 (170, 170, 170), 64 (85, 85, 85), 65 (255, 85, 85), 66 (85, 255, 85), 67 (255, 255, 85), 68 (85, 85, 255), 69 (255, 85, 255), 70 (85, 255, 255), 71 (255, 255, 255), 72 (0, 0, 0), 73 (0, 0, 95), 74 (0, 0, 135), 75 (0, 0, 175), 76 (0, 0, 215), 77 (0, 0, 255), 78 (0, 95, 0), 79 (0, 95, 95), 80 (0, 95, 135), 81 (0, 95, 175), 82 (0, 95, 215), 83 (0, 95, 255), 84 (0, 135, 0), 85 (0, 135, 95), 86 (0, 135, 135), 87 (0, 135, 175), 88 (0, 135, 215), 89 (0, 135, 255), 90 (0, 175, 0), 91 (0, 175, 95), 92 (0, 175, 135), 93 (0, 175, 175), 94 (0, 175, 215), 95 (0, 175, 255), 96 (0, 215, 0), 97 (0, 215, 95), 98 (0, 215, 135), 99 (0, 215, 175), 100 (0, 215, 215), 101 (0, 215, 255), 102 (0, 255, 0), 103 (0, 255, 95), 104 (0, 255, 135), 105 (0, 255, 175), 106 (0, 255, 215), 107 (0, 255, 255), 108 (95, 0, 0), 109 (95, 0, 95), 110 (95, 0, 135), 111 (95, 0, 175), 112 (95, 0, 215), 113 (95, 0, 255), 114 (95, 95, 0), 115 (95, 95, 95), 116 (95, 95, 135), 117 (95, 95, 175), 118 (95, 95, 215), 119 (95, 95, 255), 120 (95, 135, 0), 121 (95, 135, 95), 122 (95, 135, 135), 123 (95, 135, 175), 124 (95, 135, 215), 125 (95, 135, 255), 126 (95, 175, 0), 127 (95, 175, 95), 128 (95, 175, 135), 129 (95, 175, 175), 130 (95, 175, 215), 131 (95, 175, 255), 132 (95, 215, 0), 133 (95, 215, 95), 134 (95, 215, 135), 135 (95, 215, 175), 136 (95, 215, 215), 137 (95, 215, 255), 138 (95, 255, 0), 139 (95, 255, 95), 140 (95, 255, 135), 141 (95, 255, 175), 142 (95, 255, 215), 143 (95, 255, 255), 144 (135, 0, 0), 145 (135, 0, 95), 146 (135, 0, 135), 147 (135, 0, 175), 148 (135, 0, 215), 149 (135, 0, 255), 150 (135, 95, 0), 151 (135, 95, 95), 152 (135, 95, 135), 153 (135, 95, 175), 154 (135, 95, 215), 155 (135, 95, 255), 156 (135, 135, 0), 157 (135, 135, 95), 158 (135, 135, 135), 159 (135, 135, 175), 160 (135, 135, 215), 161 (135, 135, 255), 162 (135, 175, 0), 163 (135, 175, 95), 164 (135, 175, 135), 165 (135, 175, 175), 166 (135, 175, 215), 167 (135, 175, 255), 168 (135, 215, 0), 169 (135, 215, 95), 170 (135, 215, 135), 171 (135, 215, 175), 172 (135, 215, 215), 173 (135, 215, 255), 174 (135, 255, 0), 175 (135, 255, 95), 176 (135, 255, 135), 177 (135, 255, 175), 178 (135, 255, 215), 179 (135, 255, 255), 180 (175, 0, 0), 181 (175, 0, 95), 182 (175, 0, 135), 183 (175, 0, 175), 184 (175, 0, 215), 185 (175, 0, 255), 186 (175, 95, 0), 187 (175, 95, 95), 188 (175, 95, 135), 189 (175, 95, 175), 190 (175, 95, 215), 191 (175, 95, 255), 192 (175, 135, 0), 193 (175, 135, 95), 194 (175, 135, 135), 195 (175, 135, 175), 196 (175, 135, 215), 197 (175, 135, 255), 198 (175, 175, 0), 199 (175, 175, 95), 200 (175, 175, 135), 201 (175, 175, 175), 202 (175, 175, 215), 203 (175, 175, 255), 204 (175, 215, 0), 205 (175, 215, 95), 206 (175, 215, 135), 207 (175, 215, 175), 208 (175, 215, 215), 209 (175, 215, 255), 210 (175, 255, 0), 211 (175, 255, 95), 212 (175, 255, 135), 213 (175, 255, 175), 214 (175, 255, 215), 215 (175, 255, 255), 216 (215, 0, 0), 217 (215, 0, 95), 218 (215, 0, 135), 219 (215, 0, 175), 220 (215, 0, 215), 221 (215, 0, 255), 222 (215, 95, 0), 223 (215, 95, 95), 224 (215, 95, 135), 225 (215, 95, 175), 226 (215, 95, 215), 227 (215, 95, 255), 228 (215, 135, 0), 229 (215, 135, 95), 230 (215, 135, 135), 231 (215, 135, 175), 232 (215, 135, 215), 233 (215, 135, 255), 234 (215, 175, 0), 235 (215, 175, 95), 236 (215, 175, 135), 237 (215, 175, 175), 238 (215, 175, 215), 239 (215, 175, 255), 240 (215, 215, 0), 241 (215, 215, 95), 242 (215, 215, 135), 243 (215, 215, 175), 244 (215, 215, 215), 245 (215, 215, 255), 246 (215, 255, 0), 247 (215, 255, 95), 248 (215, 255, 135), 249 (215, 255, 175), 250 (215, 255, 215), 251 (215, 255, 255), 252 (255, 0, 0), 253 (255, 0, 95), 254 (255, 0, 135), 255 (255, 0, 175), 256 (255, 0, 215), 257 (255, 0, 255), 258 (255, 95, 0), 259 (255, 95, 95), 260 (255, 95, 135), 261 (255, 95, 175), 262 (255, 95, 215), 263 (255, 95, 255), 264 (255, 135, 0), 265 (255, 135, 95), 266 (255, 135, 135), 267 (255, 135, 175), 268 (255, 135, 215), 269 (255, 135, 255), 270 (255, 175, 0), 271 (255, 175, 95), 272 (255, 175, 135), 273 (255, 175, 175), 274 (255, 175, 215), 275 (255, 175, 255), 276 (255, 215, 0), 277 (255, 215, 95), 278 (255, 215, 135), 279 (255, 215, 175), 280 (255, 215, 215), 281 (255, 215, 255), 282 (255, 255, 0), 283 (255, 255, 95), 284 (255, 255, 135), 285 (255, 255, 175), 286 (255, 255, 215), 287 (255, 255, 255), 288 (8, 8, 8), 289 (18, 18, 18), 290 (28, 28, 28), 291 (38, 38, 38), 292 (48, 48, 48), 293 (58, 58, 58), 294 (68, 68, 68), 295 (78, 78, 78), 296 (88, 88, 88), 297 (98, 98, 98), 298 (108, 108, 108), 299 (118, 118, 118), 300 (128, 128, 128), 301 (138, 138, 138), 302 (148, 148, 148), 303 (158, 158, 158), 304 (168, 168, 168), 305 (178, 178, 178), 306 (188, 188, 188), 307 (198, 198, 198), 308 (208, 208, 208), 309 (218, 218, 218), 310 (228, 228, 228), 311 (238, 238, 238), 312] 313 314XTERM_NAMED_COLORS = { 315 0: "black", 316 1: "red", 317 2: "green", 318 3: "yellow", 319 4: "blue", 320 5: "magenta", 321 6: "cyan", 322 7: "white", 323 8: "bright-black", 324 9: "bright-red", 325 10: "bright-green", 326 11: "bright-yellow", 327 12: "bright-blue", 328 14: "bright-magenta", 329 15: "bright-cyan", 330 16: "bright-white", 331} 332 333NAMED_COLORS = {**{color: str(index) for index, color in XTERM_NAMED_COLORS.items()}} 334 335_COLOR_CACHE: dict[str, Color] = {} 336_COLOR_MATCH_CACHE: dict[tuple[float, float, float], Color] = {} 337 338 339def clear_color_cache() -> None: 340 """Clears `_COLOR_CACHE` and `_COLOR_MATCH_CACHE`.""" 341 342 _COLOR_CACHE.clear() 343 _COLOR_MATCH_CACHE.clear() 344 345 346def _get_palette_color(color: Literal["10", "11"]) -> Color: 347 """Gets either the foreground or background color of the current emulator. 348 349 Args: 350 color: The value used for `Ps` in the query. See https://unix.stackexchange.com/a/172674. 351 """ 352 353 defaults = { 354 "10": RGBColor.from_rgb((222, 222, 222)), 355 "11": RGBColor.from_rgb((20, 20, 20)), 356 } 357 358 if not terminal.isatty(): 359 return defaults[color] 360 361 sys.stdout.write(f"\x1b]{color};?\007") 362 sys.stdout.flush() 363 364 reply = getch() 365 366 match = RE_PALETTE_REPLY.match(reply) 367 if match is None: 368 return defaults[color] 369 370 _, red, green, blue = match.groups() 371 372 rgb: list[int] = [] 373 for part in (red, green, blue): 374 rgb.append(int(part[:2], base=16)) 375 376 palette_color = RGBColor.from_rgb(tuple(rgb)) # type: ignore 377 palette_color.background = color == "11" 378 379 return palette_color 380 381 382@dataclass 383class Color: 384 """A terminal color. 385 386 Args: 387 value: The data contained within this color. 388 background: Whether this color will represent a color. 389 """ 390 391 value: str 392 background: bool = False 393 394 system: ColorSystem = field(init=False) 395 396 default_foreground: Color | None = field(default=None, repr=False) 397 default_background: Color | None = field(default=None, repr=False) 398 399 _luminance: float | None = field(init=False, default=None, repr=False) 400 _brightness: float | None = field(init=False, default=None, repr=False) 401 _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False) 402 403 @classmethod 404 def from_rgb(cls, rgb: tuple[int, int, int]) -> Color: 405 """Creates a color from the given RGB, within terminal's colorsystem. 406 407 Args: 408 rgb: The RGB value to base the new color off of. 409 """ 410 411 raise NotImplementedError 412 413 @property 414 def sequence(self) -> str: 415 """Returns the ANSI sequence representation of the color.""" 416 417 raise NotImplementedError 418 419 @cached_property 420 def rgb(self) -> tuple[int, int, int]: 421 """Returns this color as a tuple of (red, green, blue) values.""" 422 423 if self._rgb is None: 424 raise NotImplementedError 425 426 return self._rgb 427 428 @cached_property 429 def hex(self) -> str: 430 """Returns CSS-like HEX representation of this color.""" 431 432 buff = "#" 433 for color in self.rgb: 434 buff += f"{format(color, 'x'):0>2}" 435 436 return buff 437 438 @classmethod 439 def get_default_foreground(cls) -> Color: 440 """Gets the terminal emulator's default foreground color.""" 441 442 if cls.default_foreground is not None: 443 return cls.default_foreground 444 445 return _get_palette_color("10") 446 447 @classmethod 448 def get_default_background(cls) -> Color: 449 """Gets the terminal emulator's default foreground color.""" 450 451 if cls.default_background is not None: 452 return cls.default_background 453 454 return _get_palette_color("11") 455 456 @property 457 def name(self) -> str: 458 """Returns the reverse-parseable name of this color.""" 459 460 return ("@" if self.background else "") + self.value 461 462 @property 463 def luminance(self) -> float: 464 """Returns this color's perceived luminance (brightness). 465 466 From https://stackoverflow.com/a/596243 467 """ 468 469 # Don't do expensive calculations over and over 470 if self._luminance is not None: 471 return self._luminance 472 473 def _linearize(color: float) -> float: 474 """Converts sRGB color to linear value.""" 475 476 if color <= 0.04045: 477 return color / 12.92 478 479 return ((color + 0.055) / 1.055) ** 2.4 480 481 red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2]) 482 483 red /= 255 484 green /= 255 485 blue /= 255 486 487 red = _linearize(red) 488 blue = _linearize(blue) 489 green = _linearize(green) 490 491 self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue 492 493 return self._luminance 494 495 @property 496 def brightness(self) -> float: 497 """Returns the perceived "brightness" of a color. 498 499 From https://stackoverflow.com/a/56678483 500 """ 501 502 # Don't do expensive calculations over and over 503 if self._brightness is not None: 504 return self._brightness 505 506 if self.luminance <= (216 / 24389): 507 brightness = self.luminance * (24389 / 27) 508 509 else: 510 brightness = self.luminance ** (1 / 3) * 116 - 16 511 512 self._brightness = brightness / 100 513 return self._brightness 514 515 def __call__(self, text: str, reset: bool = True) -> StyledText: 516 """Colors the given string, returning a `pytermgui.parser.StyledText`.""" 517 518 # We import this here as toplevel would cause circular imports, and it won't 519 # be used until this method is called anyways. 520 from .parser import StyledText # pylint: disable=import-outside-toplevel 521 522 buff = self.sequence + text 523 if reset: 524 buff += reset_style() 525 526 return StyledText(buff) 527 528 def get_localized(self) -> Color: 529 """Creates a terminal-capability local Color instance. 530 531 This method essentially allows for graceful degradation of colors in the 532 terminal. 533 """ 534 535 system = terminal.colorsystem 536 if self.system <= system: 537 return self 538 539 colortype = SYSTEM_TO_TYPE[system] 540 541 local = colortype.from_rgb(self.rgb) 542 local.background = self.background 543 544 return local 545 546 547@dataclass(repr=False) 548class IndexedColor(Color): 549 """A color representing an index into the xterm-256 color palette.""" 550 551 system = ColorSystem.EIGHT_BIT 552 553 def __post_init__(self) -> None: 554 """Ensures data validity.""" 555 556 if not self.value.isdigit(): 557 raise ValueError( 558 f"IndexedColor value has to be numerical, got {self.value!r}." 559 ) 560 561 if not 0 <= int(self.value) < 256: 562 raise ValueError( 563 f"IndexedColor value has to fit in range 0-255, got {self.value!r}." 564 ) 565 566 @classmethod 567 def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor: 568 """Constructs an `IndexedColor` from the closest matching option.""" 569 570 if rgb in _COLOR_MATCH_CACHE: 571 color = _COLOR_MATCH_CACHE[rgb] 572 573 assert isinstance(color, IndexedColor) 574 return color 575 576 if terminal.colorsystem == ColorSystem.STANDARD: 577 return StandardColor.from_rgb(rgb) 578 579 # Normalize the color values 580 red, green, blue = (x / 255 for x in rgb) 581 582 # Calculate the eight-bit color index 583 color_num = 16 584 color_num += 36 * round(red * 5.0) 585 color_num += 6 * round(green * 5.0) 586 color_num += round(blue * 5.0) 587 588 color = cls(str(color_num)) 589 _COLOR_MATCH_CACHE[rgb] = color 590 591 return color 592 593 @property 594 def sequence(self) -> str: 595 r"""Returns an ANSI sequence representing this color.""" 596 597 index = int(self.value) 598 599 return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m" 600 601 @cached_property 602 def rgb(self) -> tuple[int, int, int]: 603 """Returns an RGB representation of this color.""" 604 605 if self._rgb is not None: 606 return self._rgb 607 608 index = int(self.value) 609 rgb = COLOR_TABLE[index] 610 611 return (rgb[0], rgb[1], rgb[2]) 612 613 614class StandardColor(IndexedColor): 615 """A color in the xterm-16 palette.""" 616 617 system = ColorSystem.STANDARD 618 619 @classmethod 620 def from_rgb(cls, rgb: tuple[int, int, int]) -> StandardColor: 621 """Creates a color with the closest-matching xterm index, based on rgb. 622 623 Args: 624 rgb: The target color. 625 """ 626 627 if rgb in _COLOR_MATCH_CACHE: 628 color = _COLOR_MATCH_CACHE[rgb] 629 630 if color.system is ColorSystem.STANDARD: 631 assert isinstance(color, StandardColor) 632 return color 633 634 # Find the least-different color in the table 635 index = min(range(16), key=lambda i: _get_color_difference(rgb, COLOR_TABLE[i])) 636 color = cls(str(index)) 637 638 _COLOR_MATCH_CACHE[rgb] = color 639 640 return color 641 642 @property 643 def sequence(self) -> str: 644 r"""Returns an ANSI sequence representing this color.""" 645 646 index = int(self.value) 647 648 if index <= 7: 649 index += 30 650 651 else: 652 index = index + 82 653 654 if self.background: 655 index += 10 656 657 return f"\x1b[{index}m" 658 659 660class GreyscaleRampColor(IndexedColor): 661 """The color type used for NO_COLOR greyscale ramps. 662 663 This implementation uses the color's perceived brightness as its base. 664 """ 665 666 @classmethod 667 def from_rgb(cls, rgb: tuple[int, int, int]) -> GreyscaleRampColor: 668 """Gets a greyscale color based on the given color's luminance.""" 669 670 color = cls("0") 671 setattr(color, "_rgb", rgb) 672 673 index = int(232 + color.brightness * 23) 674 color.value = str(index) 675 676 return color 677 678 679@dataclass(repr=False) 680class RGBColor(Color): 681 """An arbitrary RGB color.""" 682 683 system = ColorSystem.TRUE 684 685 def __post_init__(self) -> None: 686 """Ensures data validity.""" 687 688 if self.value.count(";") != 2: 689 raise ValueError( 690 "Invalid value passed to RGBColor." 691 + f" Format has to be rrr;ggg;bbb, got {self.value!r}." 692 ) 693 694 rgb = tuple(int(num) for num in self.value.split(";")) 695 self._rgb = rgb[0], rgb[1], rgb[2] 696 697 @classmethod 698 def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor: 699 """Returns an `RGBColor` from the given triplet.""" 700 701 return cls(";".join(map(str, rgb))) 702 703 @property 704 def sequence(self) -> str: 705 """Returns the ANSI sequence representing this color.""" 706 707 return ( 708 "\x1b[" 709 + ("48" if self.background else "38") 710 + ";2;" 711 + ";".join(str(num) for num in self.rgb) 712 + "m" 713 ) 714 715 716@dataclass 717class HEXColor(RGBColor): 718 """An arbitrary, CSS-like HEX color.""" 719 720 system = ColorSystem.TRUE 721 722 def __post_init__(self) -> None: 723 """Ensures data validity.""" 724 725 data = self.value 726 if data.startswith("#"): 727 data = data[1:] 728 729 indices = (0, 2), (2, 4), (4, 6) 730 rgb = [] 731 for start, end in indices: 732 value = data[start:end] 733 rgb.append(int(value, base=16)) 734 735 self._rgb = rgb[0], rgb[1], rgb[2] 736 737 assert len(self._rgb) == 3 738 739 740SYSTEM_TO_TYPE: dict[ColorSystem, Type[Color]] = { 741 ColorSystem.NO_COLOR: GreyscaleRampColor, 742 ColorSystem.STANDARD: StandardColor, 743 ColorSystem.EIGHT_BIT: IndexedColor, 744 ColorSystem.TRUE: RGBColor, 745} 746 747 748def _get_color_difference( 749 rgb1: tuple[int, int, int], rgb2: tuple[int, int, int] 750) -> float: 751 """Gets the geometric difference of 2 RGB colors (0-255). 752 753 See https://en.wikipedia.org/wiki/Color_difference's Euclidian section. 754 """ 755 756 red1, green1, blue1 = rgb1 757 red2, green2, blue2 = rgb2 758 759 redmean = (red1 + red2) // 2 760 761 delta_red = red1 - red2 762 delta_green = green1 - green2 763 delta_blue = blue1 - blue2 764 765 return sqrt( 766 (2 + (redmean / 256)) * (delta_red**2) 767 + 4 * (delta_green**2) 768 + (2 + (255 - redmean) / 256) * (delta_blue**2) 769 ) 770 771 772@lru_cache(maxsize=None) 773def str_to_color( 774 text: str, 775 is_background: bool = False, 776 localize: bool = True, 777 use_cache: bool = False, 778) -> Color: 779 """Creates a `Color` from the given text. 780 781 Accepted formats: 782 - 0-255: `IndexedColor`. 783 - 'rrr;ggg;bbb': `RGBColor`. 784 - '(#)rrggbb': `HEXColor`. Leading hash is optional. 785 786 You can also add a leading '@' into the string to make the output represent a 787 background color, such as `@#123abc`. 788 789 Args: 790 text: The string to format from. 791 is_background: Whether the output should be forced into a background color. 792 Mostly used internally, when set will take precedence over syntax of leading 793 '@' symbol. 794 localize: Whether `get_localized` should be called on the output color. 795 use_cache: Whether caching should be used. 796 """ 797 798 def _trim_code(code: str) -> str: 799 """Trims the given color code.""" 800 801 is_background = code.startswith("48;") 802 803 if (code.startswith("38;5;") or code.startswith("48;5;")) or ( 804 code.startswith("38;2;") or code.startswith("48;2;") 805 ): 806 code = code[5:] 807 808 if code.endswith("m"): 809 code = code[:-1] 810 811 if is_background: 812 code = "@" + code 813 814 return code 815 816 text = _trim_code(text) 817 818 if not use_cache: 819 str_to_color.cache_clear() 820 821 if text.startswith("@"): 822 is_background = True 823 text = text[1:] 824 825 if text in NAMED_COLORS: 826 return str_to_color(str(NAMED_COLORS[text]), is_background=is_background) 827 828 color: Color 829 830 # This code is not pretty, but having these separate branches for each type 831 # should improve the performance by quite a large margin. 832 match = RE_256.match(text) 833 if match is not None: 834 # Note: At the moment, all colors become an `IndexedColor`, due to a large 835 # amount of problems a separated `StandardColor` class caused. Not 836 # sure if there are any real drawbacks to doing it this way, bar the 837 # extra characters that 255 colors use up compared to xterm-16. 838 color = IndexedColor(match[0], background=is_background) 839 840 return color.get_localized() if localize else color 841 842 match = RE_HEX.match(text) 843 if match is not None: 844 color = HEXColor(match[0], background=is_background).get_localized() 845 846 return color.get_localized() if localize else color 847 848 match = RE_RGB.match(text) 849 if match is not None: 850 color = RGBColor(match[0], background=is_background).get_localized() 851 852 return color.get_localized() if localize else color 853 854 raise ColorSyntaxError(f"Could not convert {text!r} into a `Color`.") 855 856 857def foreground(text: str, color: str | Color, reset: bool = True) -> str: 858 """Sets the foreground color of the given text. 859 860 Note that the given color will be forced into `background = True`. 861 862 Args: 863 text: The text to color. 864 color: The color to use. See `pytermgui.colors.str_to_color` for accepted 865 str formats. 866 reset: Whether the return value should include a reset sequence at the end. 867 868 Returns: 869 The colored text, including a reset if set. 870 """ 871 872 if not isinstance(color, Color): 873 color = str_to_color(color) 874 875 color.background = False 876 877 return color(text, reset=reset) 878 879 880def background(text: str, color: str | Color, reset: bool = True) -> str: 881 """Sets the background color of the given text. 882 883 Note that the given color will be forced into `background = True`. 884 885 Args: 886 text: The text to color. 887 color: The color to use. See `pytermgui.colors.str_to_color` for accepted 888 str formats. 889 reset: Whether the return value should include a reset sequence at the end. 890 891 Returns: 892 The colored text, including a reset if set. 893 """ 894 895 if not isinstance(color, Color): 896 color = str_to_color(color) 897 898 color.background = True 899 900 return color(text, reset=reset)
View Source
Clears _COLOR_CACHE
and _COLOR_MATCH_CACHE
.
View Source
858def foreground(text: str, color: str | Color, reset: bool = True) -> str: 859 """Sets the foreground color of the given text. 860 861 Note that the given color will be forced into `background = True`. 862 863 Args: 864 text: The text to color. 865 color: The color to use. See `pytermgui.colors.str_to_color` for accepted 866 str formats. 867 reset: Whether the return value should include a reset sequence at the end. 868 869 Returns: 870 The colored text, including a reset if set. 871 """ 872 873 if not isinstance(color, Color): 874 color = str_to_color(color) 875 876 color.background = False 877 878 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.
View Source
881def background(text: str, color: str | Color, reset: bool = True) -> str: 882 """Sets the background color of the given text. 883 884 Note that the given color will be forced into `background = True`. 885 886 Args: 887 text: The text to color. 888 color: The color to use. See `pytermgui.colors.str_to_color` for accepted 889 str formats. 890 reset: Whether the return value should include a reset sequence at the end. 891 892 Returns: 893 The colored text, including a reset if set. 894 """ 895 896 if not isinstance(color, Color): 897 color = str_to_color(color) 898 899 color.background = True 900 901 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.
View Source
773@lru_cache(maxsize=None) 774def str_to_color( 775 text: str, 776 is_background: bool = False, 777 localize: bool = True, 778 use_cache: bool = False, 779) -> Color: 780 """Creates a `Color` from the given text. 781 782 Accepted formats: 783 - 0-255: `IndexedColor`. 784 - 'rrr;ggg;bbb': `RGBColor`. 785 - '(#)rrggbb': `HEXColor`. Leading hash is optional. 786 787 You can also add a leading '@' into the string to make the output represent a 788 background color, such as `@#123abc`. 789 790 Args: 791 text: The string to format from. 792 is_background: Whether the output should be forced into a background color. 793 Mostly used internally, when set will take precedence over syntax of leading 794 '@' symbol. 795 localize: Whether `get_localized` should be called on the output color. 796 use_cache: Whether caching should be used. 797 """ 798 799 def _trim_code(code: str) -> str: 800 """Trims the given color code.""" 801 802 is_background = code.startswith("48;") 803 804 if (code.startswith("38;5;") or code.startswith("48;5;")) or ( 805 code.startswith("38;2;") or code.startswith("48;2;") 806 ): 807 code = code[5:] 808 809 if code.endswith("m"): 810 code = code[:-1] 811 812 if is_background: 813 code = "@" + code 814 815 return code 816 817 text = _trim_code(text) 818 819 if not use_cache: 820 str_to_color.cache_clear() 821 822 if text.startswith("@"): 823 is_background = True 824 text = text[1:] 825 826 if text in NAMED_COLORS: 827 return str_to_color(str(NAMED_COLORS[text]), is_background=is_background) 828 829 color: Color 830 831 # This code is not pretty, but having these separate branches for each type 832 # should improve the performance by quite a large margin. 833 match = RE_256.match(text) 834 if match is not None: 835 # Note: At the moment, all colors become an `IndexedColor`, due to a large 836 # amount of problems a separated `StandardColor` class caused. Not 837 # sure if there are any real drawbacks to doing it this way, bar the 838 # extra characters that 255 colors use up compared to xterm-16. 839 color = IndexedColor(match[0], background=is_background) 840 841 return color.get_localized() if localize else color 842 843 match = RE_HEX.match(text) 844 if match is not None: 845 color = HEXColor(match[0], background=is_background).get_localized() 846 847 return color.get_localized() if localize else color 848 849 match = RE_RGB.match(text) 850 if match is not None: 851 color = RGBColor(match[0], background=is_background).get_localized() 852 853 return color.get_localized() if localize else color 854 855 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.
View Source
383@dataclass 384class Color: 385 """A terminal color. 386 387 Args: 388 value: The data contained within this color. 389 background: Whether this color will represent a color. 390 """ 391 392 value: str 393 background: bool = False 394 395 system: ColorSystem = field(init=False) 396 397 default_foreground: Color | None = field(default=None, repr=False) 398 default_background: Color | None = field(default=None, repr=False) 399 400 _luminance: float | None = field(init=False, default=None, repr=False) 401 _brightness: float | None = field(init=False, default=None, repr=False) 402 _rgb: tuple[int, int, int] | None = field(init=False, default=None, repr=False) 403 404 @classmethod 405 def from_rgb(cls, rgb: tuple[int, int, int]) -> Color: 406 """Creates a color from the given RGB, within terminal's colorsystem. 407 408 Args: 409 rgb: The RGB value to base the new color off of. 410 """ 411 412 raise NotImplementedError 413 414 @property 415 def sequence(self) -> str: 416 """Returns the ANSI sequence representation of the color.""" 417 418 raise NotImplementedError 419 420 @cached_property 421 def rgb(self) -> tuple[int, int, int]: 422 """Returns this color as a tuple of (red, green, blue) values.""" 423 424 if self._rgb is None: 425 raise NotImplementedError 426 427 return self._rgb 428 429 @cached_property 430 def hex(self) -> str: 431 """Returns CSS-like HEX representation of this color.""" 432 433 buff = "#" 434 for color in self.rgb: 435 buff += f"{format(color, 'x'):0>2}" 436 437 return buff 438 439 @classmethod 440 def get_default_foreground(cls) -> Color: 441 """Gets the terminal emulator's default foreground color.""" 442 443 if cls.default_foreground is not None: 444 return cls.default_foreground 445 446 return _get_palette_color("10") 447 448 @classmethod 449 def get_default_background(cls) -> Color: 450 """Gets the terminal emulator's default foreground color.""" 451 452 if cls.default_background is not None: 453 return cls.default_background 454 455 return _get_palette_color("11") 456 457 @property 458 def name(self) -> str: 459 """Returns the reverse-parseable name of this color.""" 460 461 return ("@" if self.background else "") + self.value 462 463 @property 464 def luminance(self) -> float: 465 """Returns this color's perceived luminance (brightness). 466 467 From https://stackoverflow.com/a/596243 468 """ 469 470 # Don't do expensive calculations over and over 471 if self._luminance is not None: 472 return self._luminance 473 474 def _linearize(color: float) -> float: 475 """Converts sRGB color to linear value.""" 476 477 if color <= 0.04045: 478 return color / 12.92 479 480 return ((color + 0.055) / 1.055) ** 2.4 481 482 red, green, blue = float(self.rgb[0]), float(self.rgb[1]), float(self.rgb[2]) 483 484 red /= 255 485 green /= 255 486 blue /= 255 487 488 red = _linearize(red) 489 blue = _linearize(blue) 490 green = _linearize(green) 491 492 self._luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue 493 494 return self._luminance 495 496 @property 497 def brightness(self) -> float: 498 """Returns the perceived "brightness" of a color. 499 500 From https://stackoverflow.com/a/56678483 501 """ 502 503 # Don't do expensive calculations over and over 504 if self._brightness is not None: 505 return self._brightness 506 507 if self.luminance <= (216 / 24389): 508 brightness = self.luminance * (24389 / 27) 509 510 else: 511 brightness = self.luminance ** (1 / 3) * 116 - 16 512 513 self._brightness = brightness / 100 514 return self._brightness 515 516 def __call__(self, text: str, reset: bool = True) -> StyledText: 517 """Colors the given string, returning a `pytermgui.parser.StyledText`.""" 518 519 # We import this here as toplevel would cause circular imports, and it won't 520 # be used until this method is called anyways. 521 from .parser import StyledText # pylint: disable=import-outside-toplevel 522 523 buff = self.sequence + text 524 if reset: 525 buff += reset_style() 526 527 return StyledText(buff) 528 529 def get_localized(self) -> Color: 530 """Creates a terminal-capability local Color instance. 531 532 This method essentially allows for graceful degradation of colors in the 533 terminal. 534 """ 535 536 system = terminal.colorsystem 537 if self.system <= system: 538 return self 539 540 colortype = SYSTEM_TO_TYPE[system] 541 542 local = colortype.from_rgb(self.rgb) 543 local.background = self.background 544 545 return local
A terminal color.
Args
- value: The data contained within this color.
- background: Whether this color will represent a color.
View Source
Creates a color from the given RGB, within terminal's colorsystem.
Args
- rgb: The RGB value to base the new color off of.
Returns the ANSI sequence representation of the color.
Returns this color as a tuple of (red, green, blue) values.
Returns CSS-like HEX representation of this color.
View Source
Gets the terminal emulator's default foreground color.
View Source
Gets the terminal emulator's default foreground color.
Returns the reverse-parseable name of this 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
View Source
529 def get_localized(self) -> Color: 530 """Creates a terminal-capability local Color instance. 531 532 This method essentially allows for graceful degradation of colors in the 533 terminal. 534 """ 535 536 system = terminal.colorsystem 537 if self.system <= system: 538 return self 539 540 colortype = SYSTEM_TO_TYPE[system] 541 542 local = colortype.from_rgb(self.rgb) 543 local.background = self.background 544 545 return local
Creates a terminal-capability local Color instance.
This method essentially allows for graceful degradation of colors in the terminal.
View Source
548@dataclass(repr=False) 549class IndexedColor(Color): 550 """A color representing an index into the xterm-256 color palette.""" 551 552 system = ColorSystem.EIGHT_BIT 553 554 def __post_init__(self) -> None: 555 """Ensures data validity.""" 556 557 if not self.value.isdigit(): 558 raise ValueError( 559 f"IndexedColor value has to be numerical, got {self.value!r}." 560 ) 561 562 if not 0 <= int(self.value) < 256: 563 raise ValueError( 564 f"IndexedColor value has to fit in range 0-255, got {self.value!r}." 565 ) 566 567 @classmethod 568 def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor: 569 """Constructs an `IndexedColor` from the closest matching option.""" 570 571 if rgb in _COLOR_MATCH_CACHE: 572 color = _COLOR_MATCH_CACHE[rgb] 573 574 assert isinstance(color, IndexedColor) 575 return color 576 577 if terminal.colorsystem == ColorSystem.STANDARD: 578 return StandardColor.from_rgb(rgb) 579 580 # Normalize the color values 581 red, green, blue = (x / 255 for x in rgb) 582 583 # Calculate the eight-bit color index 584 color_num = 16 585 color_num += 36 * round(red * 5.0) 586 color_num += 6 * round(green * 5.0) 587 color_num += round(blue * 5.0) 588 589 color = cls(str(color_num)) 590 _COLOR_MATCH_CACHE[rgb] = color 591 592 return color 593 594 @property 595 def sequence(self) -> str: 596 r"""Returns an ANSI sequence representing this color.""" 597 598 index = int(self.value) 599 600 return "\x1b[" + ("48" if self.background else "38") + f";5;{index}m" 601 602 @cached_property 603 def rgb(self) -> tuple[int, int, int]: 604 """Returns an RGB representation of this color.""" 605 606 if self._rgb is not None: 607 return self._rgb 608 609 index = int(self.value) 610 rgb = COLOR_TABLE[index] 611 612 return (rgb[0], rgb[1], rgb[2])
A color representing an index into the xterm-256 color palette.
View Source
567 @classmethod 568 def from_rgb(cls, rgb: tuple[int, int, int]) -> IndexedColor: 569 """Constructs an `IndexedColor` from the closest matching option.""" 570 571 if rgb in _COLOR_MATCH_CACHE: 572 color = _COLOR_MATCH_CACHE[rgb] 573 574 assert isinstance(color, IndexedColor) 575 return color 576 577 if terminal.colorsystem == ColorSystem.STANDARD: 578 return StandardColor.from_rgb(rgb) 579 580 # Normalize the color values 581 red, green, blue = (x / 255 for x in rgb) 582 583 # Calculate the eight-bit color index 584 color_num = 16 585 color_num += 36 * round(red * 5.0) 586 color_num += 6 * round(green * 5.0) 587 color_num += round(blue * 5.0) 588 589 color = cls(str(color_num)) 590 _COLOR_MATCH_CACHE[rgb] = color 591 592 return color
Constructs an IndexedColor
from the closest matching option.
Returns an ANSI sequence representing this color.
Returns an RGB representation of this color.
View Source
680@dataclass(repr=False) 681class RGBColor(Color): 682 """An arbitrary RGB color.""" 683 684 system = ColorSystem.TRUE 685 686 def __post_init__(self) -> None: 687 """Ensures data validity.""" 688 689 if self.value.count(";") != 2: 690 raise ValueError( 691 "Invalid value passed to RGBColor." 692 + f" Format has to be rrr;ggg;bbb, got {self.value!r}." 693 ) 694 695 rgb = tuple(int(num) for num in self.value.split(";")) 696 self._rgb = rgb[0], rgb[1], rgb[2] 697 698 @classmethod 699 def from_rgb(cls, rgb: tuple[int, int, int]) -> RGBColor: 700 """Returns an `RGBColor` from the given triplet.""" 701 702 return cls(";".join(map(str, rgb))) 703 704 @property 705 def sequence(self) -> str: 706 """Returns the ANSI sequence representing this color.""" 707 708 return ( 709 "\x1b[" 710 + ("48" if self.background else "38") 711 + ";2;" 712 + ";".join(str(num) for num in self.rgb) 713 + "m" 714 )
An arbitrary RGB color.
View Source
Returns an RGBColor
from the given triplet.
Returns the ANSI sequence representing this color.
View Source
717@dataclass 718class HEXColor(RGBColor): 719 """An arbitrary, CSS-like HEX color.""" 720 721 system = ColorSystem.TRUE 722 723 def __post_init__(self) -> None: 724 """Ensures data validity.""" 725 726 data = self.value 727 if data.startswith("#"): 728 data = data[1:] 729 730 indices = (0, 2), (2, 4), (4, 6) 731 rgb = [] 732 for start, end in indices: 733 value = data[start:end] 734 rgb.append(int(value, base=16)) 735 736 self._rgb = rgb[0], rgb[1], rgb[2] 737 738 assert len(self._rgb) == 3
An arbitrary, CSS-like HEX color.