Coverage for yuio / color.py: 98%
248 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9Text background and foreground color, as well as its style, is defined
10by the :class:`yuio.color.Color` class. It stores RGB components and ANSI escape codes
11for every aspect of text presentation.
13This is a low-level module upon which :mod:`yuio.io` builds
14its higher-level abstraction.
16.. autoclass:: Color
17 :members:
19.. autoclass:: ColorValue
20 :members:
22.. autoclass:: ColorSupport
23 :members:
25"""
27from __future__ import annotations
29import colorsys
30import dataclasses
31import enum
32import re
33from dataclasses import dataclass
35from typing import TYPE_CHECKING
36from typing import ClassVar as _ClassVar
38if TYPE_CHECKING:
39 import typing_extensions as _t
40else:
41 from yuio import _typing as _t
43__all__ = [
44 "Color",
45 "ColorSupport",
46 "ColorValue",
47]
50@dataclass(frozen=True, slots=True)
51class ColorValue:
52 """
53 Data about a single color.
55 """
57 data: int | str | tuple[int, int, int]
58 """
59 Color data.
61 Can be one of three things:
63 - an int value represents an 8-bit color code (a value between ``0`` and ``7``).
65 The actual color value for 8-bit color codes is controlled by the terminal's user.
66 Therefore, it doesn't permit operations on colors.
68 Depending on where this value is used (foreground or background), it will
69 result in either ``3x`` or ``4x`` SGR parameter.
71 - an RGB-tuple represents a true color.
73 When converted for a terminal that doesn't support true colors,
74 it is automatically mapped to a corresponding 256- or 8-bit color.
76 Depending on where this value is used (foreground or background), it will
77 result in either ``38``/``3x`` or ``48``/``4x`` SGR parameter sequence.
79 - A string value represents `a parameter for the SGR command`__. Yuio will add this
80 value to an SGR escape sequence as is, without any modification.
82 __ https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters
84 """
86 _NAMES = ["BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"]
88 @classmethod
89 def from_rgb(cls, r: int, g: int, b: int, /) -> ColorValue:
90 """
91 Create a color value from rgb components.
93 Each component should be between 0 and 255.
95 :example:
96 ::
98 >>> ColorValue.from_rgb(0xA0, 0x1E, 0x9C)
99 <ColorValue #A01E9C>
101 """
103 return cls((r, g, b))
105 @classmethod
106 def from_hex(cls, h: str, /) -> ColorValue:
107 """
108 Create a color value from a hex string.
110 :example:
111 ::
113 >>> ColorValue.from_hex('#A01E9C')
114 <ColorValue #A01E9C>
116 """
118 return cls(_parse_hex(h))
120 def to_hex(self) -> str | None:
121 """
122 Return color in hex format with leading ``#``.
124 :example:
125 ::
127 >>> a = ColorValue.from_hex('#A01E9C')
128 >>> a.to_hex()
129 '#A01E9C'
131 """
133 rgb = self.to_rgb()
134 if rgb is not None:
135 return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
136 else:
137 return None
139 def to_rgb(self) -> tuple[int, int, int] | None:
140 """
141 Return RGB components of the color.
143 :example:
144 ::
146 >>> a = ColorValue.from_hex('#A01E9C')
147 >>> a.to_rgb()
148 (160, 30, 156)
150 """
152 if isinstance(self.data, tuple):
153 return self.data
154 else:
155 return None
157 def darken(self, amount: float, /) -> ColorValue:
158 """
159 Make this color darker by the given percentage.
161 Amount should be between 0 and 1.
163 :example:
164 ::
166 >>> # Darken by 30%.
167 ... ColorValue.from_hex('#A01E9C').darken(0.30)
168 <ColorValue #70156D>
170 """
172 rgb = self.to_rgb()
173 if rgb is None:
174 return self
176 amount = max(min(amount, 1), 0)
177 r, g, b = rgb
178 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF)
179 v = v - v * amount
180 r, g, b = colorsys.hsv_to_rgb(h, s, v)
181 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
183 def lighten(self, amount: float, /) -> ColorValue:
184 """
185 Make this color lighter by the given percentage.
187 Amount should be between 0 and 1.
189 :example:
190 ::
192 >>> # Lighten by 30%.
193 ... ColorValue.from_hex('#A01E9C').lighten(0.30)
194 <ColorValue #BC23B7>
196 """
198 rgb = self.to_rgb()
199 if rgb is None:
200 return self
202 amount = max(min(amount, 1), 0)
203 r, g, b = rgb
204 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF)
205 v = 1 - v
206 v = 1 - (v - v * amount)
207 r, g, b = colorsys.hsv_to_rgb(h, s, v)
208 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
210 def match_luminosity(self, other: ColorValue, /) -> ColorValue:
211 """
212 Set luminosity of this color equal to one of the other color.
214 This function will keep hue and saturation of the color intact,
215 but it will become as bright as the other color.
217 """
219 rgb1, rgb2 = self.to_rgb(), other.to_rgb()
220 if rgb1 is None or rgb2 is None:
221 return self
223 h, s, _ = colorsys.rgb_to_hsv(rgb1[0] / 0xFF, rgb1[1] / 0xFF, rgb1[2] / 0xFF)
224 _, _, v = colorsys.rgb_to_hsv(rgb2[0] / 0xFF, rgb2[1] / 0xFF, rgb2[2] / 0xFF)
225 r, g, b = colorsys.hsv_to_rgb(h, s, v)
226 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
228 @staticmethod
229 def lerp(*colors: ColorValue) -> _t.Callable[[float], ColorValue]:
230 """
231 Return a lambda that allows linear interpolation between several colors.
233 If either color is a single ANSI escape code, the first color is always returned
234 from the lambda.
236 :param colors:
237 colors of a gradient.
238 :returns:
239 a callable that allows interpolating between colors: it accepts a float
240 value between ``1`` and ``0`` and returns a color.
241 :raises:
242 :class:`ValueError` if no colors are given.
243 :example:
244 ::
246 >>> a = ColorValue.from_hex('#A01E9C')
247 >>> b = ColorValue.from_hex('#22C60C')
248 >>> lerp = ColorValue.lerp(a, b)
250 >>> lerp(0)
251 <ColorValue #A01E9C>
252 >>> lerp(0.5)
253 <ColorValue #617254>
254 >>> lerp(1)
255 <ColorValue #22C60C>
257 """
259 if not colors:
260 raise ValueError("lerp expected at least 1 argument, got 0")
261 elif len(colors) == 1 or not all(
262 isinstance(color.data, tuple) for color in colors
263 ):
264 return lambda f, /: colors[0]
265 else:
266 l = len(colors) - 1
268 def lerp(f: float, /) -> ColorValue:
269 i = int(f * l)
270 f = (f - (i / l)) * l
272 if i == l:
273 return colors[l]
274 else:
275 a, b = colors[i].data, colors[i + 1].data
276 return ColorValue(
277 tuple(int(ca + f * (cb - ca)) for ca, cb in zip(a, b)) # type: ignore
278 )
280 return lerp
282 def _as_fore(self, color_support: ColorSupport, /) -> str:
283 return self._as_code(color_support, fg_bg_prefix="3")
285 def _as_back(self, color_support: ColorSupport, /) -> str:
286 return self._as_code(color_support, fg_bg_prefix="4")
288 def _as_code(self, color_support: ColorSupport, /, fg_bg_prefix: str) -> str:
289 if color_support == ColorSupport.NONE:
290 return "" # pragma: no cover
291 elif isinstance(self.data, int):
292 return f"{fg_bg_prefix}{self.data}"
293 elif isinstance(self.data, str):
294 return self.data
295 elif color_support == ColorSupport.ANSI_TRUE:
296 return f"{fg_bg_prefix}8;2;{self.data[0]};{self.data[1]};{self.data[2]}"
297 elif color_support == ColorSupport.ANSI_256:
298 return f"{fg_bg_prefix}8;5;{_rgb_to_256(*self.data)}"
299 else:
300 return f"{fg_bg_prefix}{_rgb_to_8(*self.data)}"
302 def __repr__(self) -> str:
303 if isinstance(self.data, tuple):
304 return f"<ColorValue {self.to_hex()}>"
305 elif isinstance(self.data, int):
306 if 0 <= self.data < len(self._NAMES):
307 return f"<{self._NAMES[self.data]}>"
308 else:
309 return f"<ColorValue {self.data}>"
310 else:
311 return f"<ColorValue {self.data!r}>"
314@dataclass(frozen=True, slots=True)
315class Color:
316 """
317 Data about terminal output style. Contains
318 foreground and background color, as well as text styles.
320 When converted to an ANSI code and printed, a color completely overwrites a previous
321 color that was used by a terminal. This behavior prevents different colors and styles
322 bleeding one into another. So, for example, printing :data:`Color.STYLE_BOLD`
323 and then :data:`Color.FORE_RED` will result in non-bold red text.
325 Colors can be combined before printing, though::
327 >>> Color.STYLE_BOLD | Color.FORE_RED # Bold red
328 <Color fore=<RED> bold=True>
330 Yuio supports true RGB colors. They are automatically converted
331 to 256- or 8-bit colors if needed.
333 """
335 fore: ColorValue | None = None
336 """
337 Foreground color.
339 """
341 back: ColorValue | None = None
342 """
343 Background color.
345 """
347 bold: bool | None = None
348 """
349 If true, render text as bold.
351 """
353 dim: bool | None = None
354 """
355 If true, render text as dim.
357 """
359 italic: bool | None = None
360 """
361 If true, render text in italic font.
363 """
365 underline: bool | None = None
366 """
367 If true, underline the text.
369 """
371 inverse: bool | None = None
372 """
373 If true, swap foreground and background.
375 """
377 blink: bool | None = None
378 """
379 If true, render blinking text.
381 """
383 def __or__(self, other: Color, /):
384 return Color(
385 other.fore if other.fore is not None else self.fore,
386 other.back if other.back is not None else self.back,
387 other.bold if other.bold is not None else self.bold,
388 other.dim if other.dim is not None else self.dim,
389 other.italic if other.italic is not None else self.italic,
390 other.underline if other.underline is not None else self.underline,
391 other.inverse if other.inverse is not None else self.inverse,
392 other.blink if other.blink is not None else self.blink,
393 )
395 def __ior__(self, other: Color, /):
396 return self | other
398 @classmethod
399 def fore_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color:
400 """
401 Create a foreground color value from rgb components.
403 Each component should be between 0 and 255.
405 :example:
406 ::
408 >>> Color.fore_from_rgb(0xA0, 0x1E, 0x9C)
409 <Color fore=<ColorValue #A01E9C>>
411 """
413 return cls(fore=ColorValue.from_rgb(r, g, b), **kwargs)
415 @classmethod
416 def fore_from_hex(cls, h: str, **kwargs) -> Color:
417 """
418 Create a foreground color value from a hex string.
420 :example:
421 ::
423 >>> Color.fore_from_hex('#A01E9C')
424 <Color fore=<ColorValue #A01E9C>>
426 """
428 return cls(fore=ColorValue.from_hex(h), **kwargs)
430 @classmethod
431 def back_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color:
432 """
433 Create a background color value from rgb components.
435 Each component should be between 0 and 255.
437 :example:
438 ::
440 >>> Color.back_from_rgb(0xA0, 0x1E, 0x9C)
441 <Color back=<ColorValue #A01E9C>>
443 """
445 return cls(back=ColorValue.from_rgb(r, g, b), **kwargs)
447 @classmethod
448 def back_from_hex(cls, h: str, **kwargs) -> Color:
449 """
450 Create a background color value from a hex string.
452 :example:
453 ::
455 >>> Color.back_from_hex('#A01E9C')
456 <Color back=<ColorValue #A01E9C>>
458 """
460 return cls(back=ColorValue.from_hex(h), **kwargs)
462 @staticmethod
463 def lerp(*colors: Color) -> _t.Callable[[float], Color]:
464 """
465 Return a lambda that allows linear interpolation between several colors.
467 If either color is a single ANSI escape code, the first color is always returned
468 from the lambda.
470 :param colors:
471 colors of a gradient.
472 :returns:
473 a callable that allows interpolating between colors: it accepts a float
474 value between ``1`` and ``0`` and returns a color.
475 :raises:
476 :class:`ValueError` if no colors given.
477 :example:
478 ::
480 >>> a = Color.fore_from_hex('#A01E9C')
481 >>> b = Color.fore_from_hex('#22C60C')
482 >>> lerp = Color.lerp(a, b)
484 >>> lerp(0)
485 <Color fore=<ColorValue #A01E9C>>
486 >>> lerp(0.5)
487 <Color fore=<ColorValue #617254>>
488 >>> lerp(1)
489 <Color fore=<ColorValue #22C60C>>
491 """
493 if not colors:
494 raise ValueError("lerp expected at least 1 argument, got 0")
495 elif len(colors) == 1:
496 return lambda f, /: colors[0]
497 else:
498 fore_lerp = all(
499 color.fore is not None and isinstance(color.fore.data, tuple)
500 for color in colors
501 )
502 if fore_lerp:
503 fore = ColorValue.lerp(*(color.fore for color in colors)) # type: ignore
505 back_lerp = all(
506 color.back is not None and isinstance(color.back.data, tuple)
507 for color in colors
508 )
509 if back_lerp:
510 back = ColorValue.lerp(*(color.back for color in colors)) # type: ignore
512 if fore_lerp and back_lerp:
513 return lambda f: dataclasses.replace(
514 colors[0],
515 fore=fore(f), # type: ignore
516 back=back(f), # type: ignore
517 )
518 elif fore_lerp:
519 return lambda f: dataclasses.replace(colors[0], fore=fore(f)) # type: ignore
520 elif back_lerp:
521 return lambda f: dataclasses.replace(colors[0], back=back(f)) # type: ignore
522 else:
523 return lambda f, /: colors[0]
525 def as_code(self, color_support: ColorSupport) -> str:
526 """
527 Convert this color into an ANSI escape code with respect to the given
528 terminal capabilities.
530 :param color_support:
531 level of color support of a terminal.
532 :returns:
533 either ANSI escape code for this color or an empty string.
535 """
537 if color_support == ColorSupport.NONE:
538 return ""
540 codes = []
541 if self.fore:
542 codes.append(self.fore._as_fore(color_support))
543 if self.back:
544 codes.append(self.back._as_back(color_support))
545 if self.bold:
546 codes.append("1")
547 if self.dim:
548 codes.append("2")
549 if self.italic:
550 codes.append("3")
551 if self.underline:
552 codes.append("4")
553 if self.blink:
554 codes.append("5")
555 if self.inverse:
556 codes.append("7")
557 if codes:
558 return "\x1b[;" + ";".join(codes) + "m"
559 else:
560 return "\x1b[m"
562 def __repr__(self):
563 res = "<Color"
564 for field in dataclasses.fields(self):
565 if (value := getattr(self, field.name)) is not None:
566 res += f" {field.name}={value!r}"
567 res += ">"
568 return res
570 NONE: _ClassVar[Color] = dict() # type: ignore
571 """
572 No color.
574 """
576 STYLE_BOLD: _ClassVar[Color] = dict(bold=True) # type: ignore
577 """
578 Bold font style.
580 """
582 STYLE_DIM: _ClassVar[Color] = dict(dim=True) # type: ignore
583 """
584 Dim font style.
586 """
588 STYLE_ITALIC: _ClassVar[Color] = dict(italic=True) # type: ignore
589 """
590 Italic font style.
592 """
594 STYLE_UNDERLINE: _ClassVar[Color] = dict(underline=True) # type: ignore
595 """
596 Underline font style.
598 """
600 STYLE_INVERSE: _ClassVar[Color] = dict(inverse=True) # type: ignore
601 """
602 Swaps foreground and background colors.
604 """
606 STYLE_BLINK: _ClassVar[Color] = dict(blink=True) # type: ignore
607 """
608 Makes the text blink.
610 """
612 STYLE_NORMAL: _ClassVar[Color] = dict(
613 bold=False,
614 dim=False,
615 underline=False,
616 italic=False,
617 inverse=False,
618 blink=False,
619 ) # type: ignore
620 """
621 Normal style.
623 """
625 FORE_NORMAL: _ClassVar[Color] = dict(fore=ColorValue(9)) # type: ignore
626 """
627 Normal foreground color.
629 """
631 FORE_NORMAL_DIM: _ClassVar[Color] = dict(fore=ColorValue("2")) # type: ignore
632 """
633 Normal foreground color rendered with dim setting.
635 This is an alternative to bright black that works with
636 most terminals and color schemes.
638 """
640 FORE_BLACK: _ClassVar[Color] = dict(fore=ColorValue(0)) # type: ignore
641 """
642 Black foreground color.
644 .. warning::
646 Avoid using this color, in most terminals it is the same as background color.
647 Instead, use :attr:`~Color.FORE_NORMAL_DIM`.
649 """
651 FORE_RED: _ClassVar[Color] = dict(fore=ColorValue(1)) # type: ignore
652 """
653 Red foreground color.
655 """
657 FORE_GREEN: _ClassVar[Color] = dict(fore=ColorValue(2)) # type: ignore
658 """
659 Green foreground color.
661 """
663 FORE_YELLOW: _ClassVar[Color] = dict(fore=ColorValue(3)) # type: ignore
664 """
665 Yellow foreground color.
667 """
669 FORE_BLUE: _ClassVar[Color] = dict(fore=ColorValue(4)) # type: ignore
670 """
671 Blue foreground color.
673 """
675 FORE_MAGENTA: _ClassVar[Color] = dict(fore=ColorValue(5)) # type: ignore
676 """
677 Magenta foreground color.
679 """
681 FORE_CYAN: _ClassVar[Color] = dict(fore=ColorValue(6)) # type: ignore
682 """
683 Cyan foreground color.
685 """
687 FORE_WHITE: _ClassVar[Color] = dict(fore=ColorValue(7)) # type: ignore
688 """
689 White foreground color.
691 .. warning::
693 Avoid using it. In some terminals, notably in the Mac OS default terminal,
694 it is unreadable.
696 """
698 BACK_NORMAL: _ClassVar[Color] = dict(back=ColorValue(9)) # type: ignore
699 """
700 Normal background color.
702 """
704 BACK_BLACK: _ClassVar[Color] = dict(back=ColorValue(0)) # type: ignore
705 """
706 Black background color.
708 """
710 BACK_RED: _ClassVar[Color] = dict(back=ColorValue(1)) # type: ignore
711 """
712 Red background color.
714 """
716 BACK_GREEN: _ClassVar[Color] = dict(back=ColorValue(2)) # type: ignore
717 """
718 Green background color.
720 """
722 BACK_YELLOW: _ClassVar[Color] = dict(back=ColorValue(3)) # type: ignore
723 """
724 Yellow background color.
726 """
728 BACK_BLUE: _ClassVar[Color] = dict(back=ColorValue(4)) # type: ignore
729 """
730 Blue background color.
732 """
734 BACK_MAGENTA: _ClassVar[Color] = dict(back=ColorValue(5)) # type: ignore
735 """
736 Magenta background color.
738 """
740 BACK_CYAN: _ClassVar[Color] = dict(back=ColorValue(6)) # type: ignore
741 """
742 Cyan background color.
744 """
746 BACK_WHITE: _ClassVar[Color] = dict(back=ColorValue(7)) # type: ignore
747 """
748 White background color.
750 """
753for _n, _v in vars(Color).items():
754 if _n == _n.upper():
755 setattr(Color, _n, Color(**_v))
756del _n, _v # type: ignore
759def _parse_hex(h: str) -> tuple[int, int, int]:
760 if not re.match(r"^#[0-9a-fA-F]{6}$", h):
761 raise ValueError(f"invalid hex string {h!r}")
762 return tuple(int(h[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore
765class ColorSupport(enum.IntEnum):
766 """
767 Terminal's capability for coloring output.
769 """
771 NONE = 0
772 """
773 yuio.color.Color codes are not supported.
775 """
777 ANSI = 1
778 """
779 Only simple 8-bit color codes are supported.
781 """
783 ANSI_256 = 2
784 """
785 256-encoded colors are supported.
787 """
789 ANSI_TRUE = 3
790 """
791 True colors are supported.
793 """
795 def __repr__(self) -> str:
796 return self.name
799def _rgb_to_256(r: int, g: int, b: int) -> int:
800 closest_idx = lambda x, vals: min((abs(x - v), i) for i, v in enumerate(vals))[1]
801 color_components = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF]
803 if r == g == b:
804 i = closest_idx(r, color_components + [0x08 + 10 * i for i in range(24)])
805 if i >= len(color_components):
806 return 232 + i - len(color_components)
807 r, g, b = i, i, i
808 else:
809 r, g, b = (closest_idx(x, color_components) for x in (r, g, b))
810 return r * 36 + g * 6 + b + 16
813def _rgb_to_8(r: int, g: int, b: int) -> int:
814 return (
815 (1 if r >= 128 else 0)
816 | (1 if g >= 128 else 0) << 1
817 | (1 if b >= 128 else 0) << 2
818 )