Coverage for yuio / color.py: 87%
248 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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
33import typing
34from dataclasses import dataclass
36from yuio import _typing as _t
38__all__ = [
39 "Color",
40 "ColorSupport",
41 "ColorValue",
42]
45@dataclass(frozen=True, slots=True)
46class ColorValue:
47 """
48 Data about a single color.
50 """
52 data: int | str | tuple[int, int, int]
53 """
54 Color data.
56 Can be one of three things:
58 - an int value represents an 8-bit color code (a value between ``0`` and ``7``).
60 The actual color value for 8-bit color codes is controlled by the terminal's user.
61 Therefore, it doesn't permit operations on colors.
63 Depending on where this value is used (foreground or background), it will
64 result in either ``3x`` or ``4x`` SGR parameter.
66 - an RGB-tuple represents a true color.
68 When converted for a terminal that doesn't support true colors,
69 it is automatically mapped to a corresponding 256- or 8-bit color.
71 Depending on where this value is used (foreground or background), it will
72 result in either ``38``/``3x`` or ``48``/``4x`` SGR parameter sequence.
74 - A string value represents `a parameter for the SGR command`__. Yuio will add this
75 value to an SGR escape sequence as is, without any modification.
77 __ https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters
79 """
81 _NAMES = ["BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"]
83 @classmethod
84 def from_rgb(cls, r: int, g: int, b: int, /) -> ColorValue:
85 """
86 Create a color value from rgb components.
88 Each component should be between 0 and 255.
90 :example:
91 ::
93 >>> ColorValue.from_rgb(0xA0, 0x1E, 0x9C)
94 <ColorValue #A01E9C>
96 """
98 return cls((r, g, b))
100 @classmethod
101 def from_hex(cls, h: str, /) -> ColorValue:
102 """
103 Create a color value from a hex string.
105 :example:
106 ::
108 >>> ColorValue.from_hex('#A01E9C')
109 <ColorValue #A01E9C>
111 """
113 return cls(_parse_hex(h))
115 def to_hex(self) -> str | None:
116 """
117 Return color in hex format with leading ``#``.
119 :example:
120 ::
122 >>> a = ColorValue.from_hex('#A01E9C')
123 >>> a.to_hex()
124 '#A01E9C'
126 """
128 rgb = self.to_rgb()
129 if rgb is not None:
130 return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
131 else:
132 return None
134 def to_rgb(self) -> tuple[int, int, int] | None:
135 """
136 Return RGB components of the color.
138 :example:
139 ::
141 >>> a = ColorValue.from_hex('#A01E9C')
142 >>> a.to_rgb()
143 (160, 30, 156)
145 """
147 if isinstance(self.data, tuple):
148 return self.data
149 else:
150 return None
152 def darken(self, amount: float, /) -> ColorValue:
153 """
154 Make this color darker by the given percentage.
156 Amount should be between 0 and 1.
158 :example:
159 ::
161 >>> # Darken by 30%.
162 ... ColorValue.from_hex('#A01E9C').darken(0.30)
163 <ColorValue #70156D>
165 """
167 rgb = self.to_rgb()
168 if rgb is None:
169 return self
171 amount = max(min(amount, 1), 0)
172 r, g, b = rgb
173 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF)
174 v = v - v * amount
175 r, g, b = colorsys.hsv_to_rgb(h, s, v)
176 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
178 def lighten(self, amount: float, /) -> ColorValue:
179 """
180 Make this color lighter by the given percentage.
182 Amount should be between 0 and 1.
184 :example:
185 ::
187 >>> # Lighten by 30%.
188 ... ColorValue.from_hex('#A01E9C').lighten(0.30)
189 <ColorValue #BC23B7>
191 """
193 rgb = self.to_rgb()
194 if rgb is None:
195 return self
197 amount = max(min(amount, 1), 0)
198 r, g, b = rgb
199 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF)
200 v = 1 - v
201 v = 1 - (v - v * amount)
202 r, g, b = colorsys.hsv_to_rgb(h, s, v)
203 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
205 def match_luminosity(self, other: ColorValue, /) -> ColorValue:
206 """
207 Set luminosity of this color equal to one of the other color.
209 This function will keep hue and saturation of the color intact,
210 but it will become as bright as the other color.
212 """
214 rgb1, rgb2 = self.to_rgb(), other.to_rgb()
215 if rgb1 is None or rgb2 is None:
216 return self
218 h, s, _ = colorsys.rgb_to_hsv(rgb1[0] / 0xFF, rgb1[1] / 0xFF, rgb1[2] / 0xFF)
219 _, _, v = colorsys.rgb_to_hsv(rgb2[0] / 0xFF, rgb2[1] / 0xFF, rgb2[2] / 0xFF)
220 r, g, b = colorsys.hsv_to_rgb(h, s, v)
221 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF))
223 @staticmethod
224 def lerp(*colors: ColorValue) -> _t.Callable[[float], ColorValue]:
225 """
226 Return a lambda that allows linear interpolation between several colors.
228 If either color is a single ANSI escape code, the first color is always returned
229 from the lambda.
231 :param colors:
232 colors of a gradient.
233 :returns:
234 a callable that allows interpolating between colors: it accepts a float
235 value between ``1`` and ``0`` and returns a color.
236 :raises:
237 :class:`ValueError` if no colors are given.
238 :example:
239 ::
241 >>> a = ColorValue.from_hex('#A01E9C')
242 >>> b = ColorValue.from_hex('#22C60C')
243 >>> lerp = ColorValue.lerp(a, b)
245 >>> lerp(0)
246 <ColorValue #A01E9C>
247 >>> lerp(0.5)
248 <ColorValue #617254>
249 >>> lerp(1)
250 <ColorValue #22C60C>
252 """
254 if not colors:
255 raise ValueError("lerp expected at least 1 argument, got 0")
256 elif len(colors) == 1 or not all(
257 isinstance(color.data, tuple) for color in colors
258 ):
259 return lambda f, /: colors[0]
260 else:
261 l = len(colors) - 1
263 def lerp(f: float, /) -> ColorValue:
264 i = int(f * l)
265 f = (f - (i / l)) * l
267 if i == l:
268 return colors[l]
269 else:
270 a, b = colors[i].data, colors[i + 1].data
271 return ColorValue(
272 tuple(int(ca + f * (cb - ca)) for ca, cb in zip(a, b)) # type: ignore
273 )
275 return lerp
277 def _as_fore(self, color_support: ColorSupport, /) -> str:
278 return self._as_code(color_support, fg_bg_prefix="3")
280 def _as_back(self, color_support: ColorSupport, /) -> str:
281 return self._as_code(color_support, fg_bg_prefix="4")
283 def _as_code(self, color_support: ColorSupport, /, fg_bg_prefix: str) -> str:
284 if color_support == ColorSupport.NONE:
285 return ""
286 elif isinstance(self.data, int):
287 return f"{fg_bg_prefix}{self.data}"
288 elif isinstance(self.data, str):
289 return self.data
290 elif color_support == ColorSupport.ANSI_TRUE:
291 return f"{fg_bg_prefix}8;2;{self.data[0]};{self.data[1]};{self.data[2]}"
292 elif color_support == ColorSupport.ANSI_256:
293 return f"{fg_bg_prefix}8;5;{_rgb_to_256(*self.data)}"
294 else:
295 return f"{fg_bg_prefix}{_rgb_to_8(*self.data)}"
297 def __repr__(self) -> str:
298 if isinstance(self.data, tuple):
299 return f"<ColorValue {self.to_hex()}>"
300 elif isinstance(self.data, int):
301 if 0 <= self.data < len(self._NAMES):
302 return f"<{self._NAMES[self.data]}>"
303 else:
304 return f"<ColorValue {self.data}>"
305 else:
306 return f"<ColorValue {self.data!r}>"
309@dataclass(frozen=True, slots=True)
310class Color:
311 """
312 Data about terminal output style. Contains
313 foreground and background color, as well as text styles.
315 When converted to an ANSI code and printed, a color completely overwrites a previous
316 color that was used by a terminal. This behavior prevents different colors and styles
317 bleeding one into another. So, for example, printing :data:`Color.STYLE_BOLD`
318 and then :data:`Color.FORE_RED` will result in non-bold red text.
320 Colors can be combined before printing, though::
322 >>> Color.STYLE_BOLD | Color.FORE_RED # Bold red
323 <Color fore=<RED> bold=True>
325 Yuio supports true RGB colors. They are automatically converted
326 to 256- or 8-bit colors if needed.
328 """
330 fore: ColorValue | None = None
331 """
332 Foreground color.
334 """
336 back: ColorValue | None = None
337 """
338 Background color.
340 """
342 bold: bool | None = None
343 """
344 If true, render text as bold.
346 """
348 dim: bool | None = None
349 """
350 If true, render text as dim.
352 """
354 italic: bool | None = None
355 """
356 If true, render text in italic font.
358 """
360 underline: bool | None = None
361 """
362 If true, render underline the text.
364 """
366 inverse: bool | None = None
367 """
368 If true, swap foreground and background.
370 """
372 blink: bool | None = None
373 """
374 If true, render blinking text.
376 """
378 def __or__(self, other: Color, /):
379 return Color(
380 other.fore if other.fore is not None else self.fore,
381 other.back if other.back is not None else self.back,
382 other.bold if other.bold is not None else self.bold,
383 other.dim if other.dim is not None else self.dim,
384 other.italic if other.italic is not None else self.italic,
385 other.underline if other.underline is not None else self.underline,
386 other.inverse if other.inverse is not None else self.inverse,
387 other.blink if other.blink is not None else self.blink,
388 )
390 def __ior__(self, other: Color, /):
391 return self | other
393 @classmethod
394 def fore_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color:
395 """
396 Create a foreground color value from rgb components.
398 Each component should be between 0 and 255.
400 :example:
401 ::
403 >>> Color.fore_from_rgb(0xA0, 0x1E, 0x9C)
404 <Color fore=<ColorValue #A01E9C>>
406 """
408 return cls(fore=ColorValue.from_rgb(r, g, b), **kwargs)
410 @classmethod
411 def fore_from_hex(cls, h: str, **kwargs) -> Color:
412 """
413 Create a foreground color value from a hex string.
415 :example:
416 ::
418 >>> Color.fore_from_hex('#A01E9C')
419 <Color fore=<ColorValue #A01E9C>>
421 """
423 return cls(fore=ColorValue.from_hex(h), **kwargs)
425 @classmethod
426 def back_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color:
427 """
428 Create a background color value from rgb components.
430 Each component should be between 0 and 255.
432 :example:
433 ::
435 >>> Color.back_from_rgb(0xA0, 0x1E, 0x9C)
436 <Color back=<ColorValue #A01E9C>>
438 """
440 return cls(back=ColorValue.from_rgb(r, g, b), **kwargs)
442 @classmethod
443 def back_from_hex(cls, h: str, **kwargs) -> Color:
444 """
445 Create a background color value from a hex string.
447 :example:
448 ::
450 >>> Color.back_from_hex('#A01E9C')
451 <Color back=<ColorValue #A01E9C>>
453 """
455 return cls(back=ColorValue.from_hex(h), **kwargs)
457 @staticmethod
458 def lerp(*colors: Color) -> _t.Callable[[float], Color]:
459 """
460 Return a lambda that allows linear interpolation between several colors.
462 If either color is a single ANSI escape code, the first color is always returned
463 from the lambda.
465 :param colors:
466 colors of a gradient.
467 :returns:
468 a callable that allows interpolating between colors: it accepts a float
469 value between ``1`` and ``0`` and returns a color.
470 :raises:
471 :class:`ValueError` if no colors given.
472 :example:
473 ::
475 >>> a = Color.fore_from_hex('#A01E9C')
476 >>> b = Color.fore_from_hex('#22C60C')
477 >>> lerp = Color.lerp(a, b)
479 >>> lerp(0)
480 <Color fore=<ColorValue #A01E9C>>
481 >>> lerp(0.5)
482 <Color fore=<ColorValue #617254>>
483 >>> lerp(1)
484 <Color fore=<ColorValue #22C60C>>
486 """
488 if not colors:
489 raise ValueError("lerp expected at least 1 argument, got 0")
490 elif len(colors) == 1:
491 return lambda f, /: colors[0]
492 else:
493 fore_lerp = all(
494 color.fore is not None and isinstance(color.fore.data, tuple)
495 for color in colors
496 )
497 if fore_lerp:
498 fore = ColorValue.lerp(*(color.fore for color in colors)) # type: ignore
500 back_lerp = all(
501 color.back is not None and isinstance(color.back.data, tuple)
502 for color in colors
503 )
504 if back_lerp:
505 back = ColorValue.lerp(*(color.back for color in colors)) # type: ignore
507 if fore_lerp and back_lerp:
508 return lambda f: dataclasses.replace(
509 colors[0],
510 fore=fore(f), # type: ignore
511 back=back(f), # type: ignore
512 )
513 elif fore_lerp:
514 return lambda f: dataclasses.replace(colors[0], fore=fore(f)) # type: ignore
515 elif back_lerp:
516 return lambda f: dataclasses.replace(colors[0], back=back(f)) # type: ignore
517 else:
518 return lambda f, /: colors[0]
520 def as_code(self, color_support: ColorSupport) -> str:
521 """
522 Convert this color into an ANSI escape code with respect to the given
523 terminal capabilities.
525 :param color_support:
526 level of color support of a terminal.
527 :returns:
528 either ANSI escape code for this color or an empty string.
530 """
532 if color_support == ColorSupport.NONE:
533 return ""
535 codes = []
536 if self.fore:
537 codes.append(self.fore._as_fore(color_support))
538 if self.back:
539 codes.append(self.back._as_back(color_support))
540 if self.bold:
541 codes.append("1")
542 if self.dim:
543 codes.append("2")
544 if self.italic:
545 codes.append("3")
546 if self.underline:
547 codes.append("4")
548 if self.blink:
549 codes.append("5")
550 if self.inverse:
551 codes.append("7")
552 if codes:
553 return "\x1b[;" + ";".join(codes) + "m"
554 else:
555 return "\x1b[m"
557 def __repr__(self):
558 res = "<Color"
559 for field in dataclasses.fields(self):
560 if (value := getattr(self, field.name)) is not None:
561 res += f" {field.name}={value!r}"
562 res += ">"
563 return res
565 NONE: typing.ClassVar[Color] = dict() # type: ignore
566 """
567 No color.
569 """
571 STYLE_BOLD: typing.ClassVar[Color] = dict(bold=True) # type: ignore
572 """
573 Bold font style.
575 """
577 STYLE_DIM: typing.ClassVar[Color] = dict(dim=True) # type: ignore
578 """
579 Dim font style.
581 """
583 STYLE_ITALIC: typing.ClassVar[Color] = dict(italic=True) # type: ignore
584 """
585 Italic font style.
587 """
589 STYLE_UNDERLINE: typing.ClassVar[Color] = dict(underline=True) # type: ignore
590 """
591 Underline font style.
593 """
595 STYLE_INVERSE: typing.ClassVar[Color] = dict(inverse=True) # type: ignore
596 """
597 Swaps foreground and background colors.
599 """
601 STYLE_BLINK: typing.ClassVar[Color] = dict(blink=True) # type: ignore
602 """
603 Makes the text blink.
605 """
607 STYLE_NORMAL: typing.ClassVar[Color] = dict(
608 bold=False,
609 dim=False,
610 underline=False,
611 italic=False,
612 inverse=False,
613 blink=False,
614 ) # type: ignore
615 """
616 Normal style.
618 """
620 FORE_NORMAL: typing.ClassVar[Color] = dict(fore=ColorValue(9)) # type: ignore
621 """
622 Normal foreground color.
624 """
626 FORE_NORMAL_DIM: typing.ClassVar[Color] = dict(fore=ColorValue("2")) # type: ignore
627 """
628 Normal foreground color rendered with dim setting.
630 This is an alternative to bright black that works with
631 most terminals and color schemes.
633 """
635 FORE_BLACK: typing.ClassVar[Color] = dict(fore=ColorValue(0)) # type: ignore
636 """
637 Black foreground color.
639 .. warning::
641 Avoid using this color, in most terminals it is the same as background color.
642 Instead, use :attr:`~Color.FORE_NORMAL_DIM`.
644 """
646 FORE_RED: typing.ClassVar[Color] = dict(fore=ColorValue(1)) # type: ignore
647 """
648 Red foreground color.
650 """
652 FORE_GREEN: typing.ClassVar[Color] = dict(fore=ColorValue(2)) # type: ignore
653 """
654 Green foreground color.
656 """
658 FORE_YELLOW: typing.ClassVar[Color] = dict(fore=ColorValue(3)) # type: ignore
659 """
660 Yellow foreground color.
662 """
664 FORE_BLUE: typing.ClassVar[Color] = dict(fore=ColorValue(4)) # type: ignore
665 """
666 Blue foreground color.
668 """
670 FORE_MAGENTA: typing.ClassVar[Color] = dict(fore=ColorValue(5)) # type: ignore
671 """
672 Magenta foreground color.
674 """
676 FORE_CYAN: typing.ClassVar[Color] = dict(fore=ColorValue(6)) # type: ignore
677 """
678 Cyan foreground color.
680 """
682 FORE_WHITE: typing.ClassVar[Color] = dict(fore=ColorValue(7)) # type: ignore
683 """
684 White foreground color.
686 .. warning::
688 Avoid using it. In some terminals, notably in the Mac OS default terminal,
689 it is unreadable.
691 """
693 BACK_NORMAL: typing.ClassVar[Color] = dict(back=ColorValue(9)) # type: ignore
694 """
695 Normal background color.
697 """
699 BACK_BLACK: typing.ClassVar[Color] = dict(back=ColorValue(0)) # type: ignore
700 """
701 Black background color.
703 """
705 BACK_RED: typing.ClassVar[Color] = dict(back=ColorValue(1)) # type: ignore
706 """
707 Red background color.
709 """
711 BACK_GREEN: typing.ClassVar[Color] = dict(back=ColorValue(2)) # type: ignore
712 """
713 Green background color.
715 """
717 BACK_YELLOW: typing.ClassVar[Color] = dict(back=ColorValue(3)) # type: ignore
718 """
719 Yellow background color.
721 """
723 BACK_BLUE: typing.ClassVar[Color] = dict(back=ColorValue(4)) # type: ignore
724 """
725 Blue background color.
727 """
729 BACK_MAGENTA: typing.ClassVar[Color] = dict(back=ColorValue(5)) # type: ignore
730 """
731 Magenta background color.
733 """
735 BACK_CYAN: typing.ClassVar[Color] = dict(back=ColorValue(6)) # type: ignore
736 """
737 Cyan background color.
739 """
741 BACK_WHITE: typing.ClassVar[Color] = dict(back=ColorValue(7)) # type: ignore
742 """
743 White background color.
745 """
748for _n, _v in vars(Color).items():
749 if _n == _n.upper():
750 setattr(Color, _n, Color(**_v))
751del _n, _v # type: ignore
754def _parse_hex(h: str) -> tuple[int, int, int]:
755 if not re.match(r"^#[0-9a-fA-F]{6}$", h):
756 raise ValueError(f"invalid hex string {h!r}")
757 return tuple(int(h[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore
760class ColorSupport(enum.IntEnum):
761 """
762 Terminal's capability for coloring output.
764 """
766 NONE = 0
767 """
768 yuio.color.Color codes are not supported.
770 """
772 ANSI = 1
773 """
774 Only simple 8-bit color codes are supported.
776 """
778 ANSI_256 = 2
779 """
780 256-encoded colors are supported.
782 """
784 ANSI_TRUE = 3
785 """
786 True colors are supported.
788 """
791def _rgb_to_256(r: int, g: int, b: int) -> int:
792 closest_idx = lambda x, vals: min((abs(x - v), i) for i, v in enumerate(vals))[1]
793 color_components = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF]
795 if r == g == b:
796 i = closest_idx(r, color_components + [0x08 + 10 * i for i in range(24)])
797 if i >= len(color_components):
798 return 232 + i - len(color_components)
799 r, g, b = i, i, i
800 else:
801 r, g, b = (closest_idx(x, color_components) for x in (r, g, b))
802 return r * 36 + g * 6 + b + 16
805def _rgb_to_8(r: int, g: int, b: int) -> int:
806 return (
807 (1 if r >= 128 else 0)
808 | (1 if g >= 128 else 0) << 1
809 | (1 if b >= 128 else 0) << 2
810 )