2 from functools
import lru_cache
3 from marshal
import dumps
, loads
4 from random
import randint
5 from typing
import Any
, Dict
, Iterable
, List
, Optional
, Type
, Union
, cast
8 from .color
import Color
, ColorParseError
, ColorSystem
, blend_rgb
9 from .repr import Result
, rich_repr
10 from .terminal_theme
import DEFAULT_TERMINAL_THEME
, TerminalTheme
12 # Style instances and style definitions are often interchangeable
13 StyleType
= Union
[str, "Style"]
17 """A descriptor to get/set a style attribute bit."""
21 def __init__(self
, bit_no
: int) -> None:
22 self
.bit
= 1 << bit_no
24 def __get__(self
, obj
: "Style", objtype
: Type
["Style"]) -> Optional
[bool]:
25 if obj
._set
_attributes
& self
.bit
:
26 return obj
._attributes
& self
.bit
!= 0
34 A terminal style consists of a color (`color`), a background color (`bgcolor`), and a number of attributes, such
35 as bold, italic etc. The attributes have 3 states: they can either be on
36 (``True``), off (``False``), or not set (``None``).
39 color (Union[Color, str], optional): Color of terminal text. Defaults to None.
40 bgcolor (Union[Color, str], optional): Color of terminal background. Defaults to None.
41 bold (bool, optional): Enable bold text. Defaults to None.
42 dim (bool, optional): Enable dim text. Defaults to None.
43 italic (bool, optional): Enable italic text. Defaults to None.
44 underline (bool, optional): Enable underlined text. Defaults to None.
45 blink (bool, optional): Enabled blinking text. Defaults to None.
46 blink2 (bool, optional): Enable fast blinking text. Defaults to None.
47 reverse (bool, optional): Enabled reverse text. Defaults to None.
48 conceal (bool, optional): Enable concealed text. Defaults to None.
49 strike (bool, optional): Enable strikethrough text. Defaults to None.
50 underline2 (bool, optional): Enable doubly underlined text. Defaults to None.
51 frame (bool, optional): Enable framed text. Defaults to None.
52 encircle (bool, optional): Enable encircled text. Defaults to None.
53 overline (bool, optional): Enable overlined text. Defaults to None.
54 link (str, link): Link URL. Defaults to None.
58 _color
: Optional
[Color
]
59 _bgcolor
: Optional
[Color
]
64 _meta
: Optional
[bytes]
80 # maps bits on to SGR parameter
104 "underline": "underline",
108 "reverse": "reverse",
110 "conceal": "conceal",
114 "underline2": "underline2",
117 "encircle": "encircle",
118 "overline": "overline",
125 color
: Optional
[Union
[Color
, str]] = None,
126 bgcolor
: Optional
[Union
[Color
, str]] = None,
127 bold
: Optional
[bool] = None,
128 dim
: Optional
[bool] = None,
129 italic
: Optional
[bool] = None,
130 underline
: Optional
[bool] = None,
131 blink
: Optional
[bool] = None,
132 blink2
: Optional
[bool] = None,
133 reverse
: Optional
[bool] = None,
134 conceal
: Optional
[bool] = None,
135 strike
: Optional
[bool] = None,
136 underline2
: Optional
[bool] = None,
137 frame
: Optional
[bool] = None,
138 encircle
: Optional
[bool] = None,
139 overline
: Optional
[bool] = None,
140 link
: Optional
[str] = None,
141 meta
: Optional
[Dict
[str, Any
]] = None,
143 self
._ansi
: Optional
[str] = None
144 self
._style
_definition
: Optional
[str] = None
146 def _make_color(color
: Union
[Color
, str]) -> Color
:
147 return color
if isinstance(color
, Color
) else Color
.parse(color
)
149 self
._color
= None if color
is None else _make_color(color
)
150 self
._bgcolor
= None if bgcolor
is None else _make_color(bgcolor
)
151 self
._set
_attributes
= sum(
154 dim
is not None and 2,
155 italic
is not None and 4,
156 underline
is not None and 8,
157 blink
is not None and 16,
158 blink2
is not None and 32,
159 reverse
is not None and 64,
160 conceal
is not None and 128,
161 strike
is not None and 256,
162 underline2
is not None and 512,
163 frame
is not None and 1024,
164 encircle
is not None and 2048,
165 overline
is not None and 4096,
174 underline
and 8 or 0,
178 conceal
and 128 or 0,
180 underline2
and 512 or 0,
182 encircle
and 2048 or 0,
183 overline
and 4096 or 0,
186 if self
._set
_attributes
191 self
._meta
= None if meta
is None else dumps(meta
)
193 f
"{randint(0, 999999)}{hash(self._meta)}" if (link
or meta
) else ""
195 self
._hash
: Optional
[int] = None
196 self
._null
= not (self
._set
_attributes
or color
or bgcolor
or link
or meta
)
199 def null(cls
) -> "Style":
200 """Create an 'null' style, equivalent to Style(), but more performant."""
205 cls
, color
: Optional
[Color
] = None, bgcolor
: Optional
[Color
] = None
207 """Create a new style with colors and no attributes.
210 color (Optional[Color]): A (foreground) color, or None for no color. Defaults to None.
211 bgcolor (Optional[Color]): A (background) color, or None for no color. Defaults to None.
213 style
: Style
= cls
.__new
__(Style
)
215 style
._style
_definition
= None
217 style
._bgcolor
= bgcolor
218 style
._set
_attributes
= 0
219 style
._attributes
= 0
223 style
._null
= not (color
or bgcolor
)
228 def from_meta(cls
, meta
: Optional
[Dict
[str, Any
]]) -> "Style":
229 """Create a new style with meta data.
232 meta (Optional[Dict[str, Any]]): A dictionary of meta data. Defaults to None.
234 style
: Style
= cls
.__new
__(Style
)
236 style
._style
_definition
= None
238 style
._bgcolor
= None
239 style
._set
_attributes
= 0
240 style
._attributes
= 0
242 style
._meta
= dumps(meta
)
243 style
._link
_id
= f
"{randint(0, 999999)}{hash(style._meta)}"
245 style
._null
= not (meta
)
249 def on(cls
, meta
: Optional
[Dict
[str, Any
]] = None, **handlers
: Any
) -> "Style":
250 """Create a blank style with meta information.
253 style = Style.on(click=self.on_click)
256 meta (Optional[Dict[str, Any]], optional): An optional dict of meta information.
257 **handlers (Any): Keyword arguments are translated in to handlers.
260 Style: A Style with meta information attached.
262 meta
= {} if meta
is None else meta
263 meta
.update({f"@{key}
": value for key, value in handlers.items()})
264 return cls.from_meta(meta)
281 def link_id(self) -> str:
282 """Get a link id, used in ansi code for links."""
285 def __str__(self) -> str:
286 """Re-generate style definition from attributes."""
287 if self._style_definition is None:
288 attributes: List[str] = []
289 append = attributes.append
290 bits = self._set_attributes
291 if bits & 0b0000000001111:
293 append("bold
" if self.bold else "not bold
")
295 append("dim
" if self.dim else "not dim
")
297 append("italic
" if self.italic else "not italic
")
299 append("underline
" if self.underline else "not underline
")
300 if bits & 0b0000111110000:
302 append("blink
" if self.blink else "not blink
")
304 append("blink2
" if self.blink2 else "not blink2
")
306 append("reverse
" if self.reverse else "not reverse
")
308 append("conceal
" if self.conceal else "not conceal
")
310 append("strike
" if self.strike else "not strike
")
311 if bits & 0b1111000000000:
313 append("underline2
" if self.underline2 else "not underline2
")
315 append("frame
" if self.frame else "not frame
")
317 append("encircle
" if self.encircle else "not encircle
")
319 append("overline
" if self.overline else "not overline
")
320 if self._color is not None:
321 append(self._color.name)
322 if self._bgcolor is not None:
324 append(self._bgcolor.name)
328 self._style_definition = " ".join(attributes) or "none
"
329 return self._style_definition
331 def __bool__(self) -> bool:
332 """A Style is false if it has no attributes, colors, or links."""
333 return not self._null
335 def _make_ansi_codes(self, color_system: ColorSystem) -> str:
336 """Generate ANSI codes for this style.
339 color_system (ColorSystem): Color system.
342 str: String containing codes.
345 if self._ansi is None:
348 _style_map = self._style_map
349 attributes = self._attributes & self._set_attributes
352 append(_style_map[0])
354 append(_style_map[1])
356 append(_style_map[2])
358 append(_style_map[3])
359 if attributes & 0b0000111110000:
360 for bit in range(4, 9):
361 if attributes & (1 << bit):
362 append(_style_map[bit])
363 if attributes & 0b1111000000000:
364 for bit in range(9, 13):
365 if attributes & (1 << bit):
366 append(_style_map[bit])
367 if self._color is not None:
368 sgr.extend(self._color.downgrade(color_system).get_ansi_codes())
369 if self._bgcolor is not None:
371 self._bgcolor.downgrade(color_system).get_ansi_codes(
375 self._ansi = ";".join(sgr)
379 @lru_cache(maxsize=1024)
380 def normalize(cls, style: str) -> str:
381 """Normalize a style definition so that styles with the same effect have the same string
385 style (str): A style definition.
388 str: Normal form of style definition.
391 return str(cls.parse(style))
392 except errors.StyleSyntaxError:
393 return style.strip().lower()
396 def pick_first(cls, *values: Optional[StyleType]) -> StyleType:
397 """Pick first non-None style."""
399 if value is not None:
401 raise ValueError("expected at least one non
-None style
")
403 def __rich_repr__(self) -> Result:
404 yield "color
", self.color, None
405 yield "bgcolor
", self.bgcolor, None
406 yield "bold
", self.bold, None,
407 yield "dim
", self.dim, None,
408 yield "italic
", self.italic, None
409 yield "underline
", self.underline, None,
410 yield "blink
", self.blink, None
411 yield "blink2
", self.blink2, None
412 yield "reverse
", self.reverse, None
413 yield "conceal
", self.conceal, None
414 yield "strike
", self.strike, None
415 yield "underline2
", self.underline2, None
416 yield "frame
", self.frame, None
417 yield "encircle
", self.encircle, None
418 yield "link
", self.link, None
420 yield "meta
", self.meta
422 def __eq__(self, other: Any) -> bool:
423 if not isinstance(other, Style):
424 return NotImplemented
425 return self.__hash__() == other.__hash__()
427 def __ne__(self, other: Any) -> bool:
428 if not isinstance(other, Style):
429 return NotImplemented
430 return self.__hash__() != other.__hash__()
432 def __hash__(self) -> int:
433 if self._hash is not None:
440 self._set_attributes,
448 def color(self) -> Optional[Color]:
449 """The foreground color or None if it is not set."""
453 def bgcolor(self) -> Optional[Color]:
454 """The background color or None if it is not set."""
458 def link(self) -> Optional[str]:
459 """Link text, if set."""
463 def transparent_background(self) -> bool:
464 """Check if the style specified a transparent background."""
465 return self.bgcolor is None or self.bgcolor.is_default
468 def background_style(self) -> "Style
":
469 """A Style with background only."""
470 return Style(bgcolor=self.bgcolor)
473 def meta(self) -> Dict[str, Any]:
474 """Get meta information (can not be changed after construction)."""
475 return {} if self._meta is None else cast(Dict[str, Any], loads(self._meta))
478 def without_color(self) -> "Style
":
479 """Get a copy of the style with color removed."""
482 style: Style = self.__new__(Style)
484 style._style_definition = None
486 style._bgcolor = None
487 style._attributes = self._attributes
488 style._set_attributes = self._set_attributes
489 style._link = self._link
490 style._link_id = f"{randint(0, 999999)}
" if self._link else ""
497 @lru_cache(maxsize=4096)
498 def parse(cls, style_definition: str) -> "Style
":
499 """Parse a style definition.
502 style_definition (str): A string containing a style.
505 errors.StyleSyntaxError: If the style definition syntax is invalid.
508 `Style`: A Style instance.
510 if style_definition.strip() == "none
" or not style_definition:
513 STYLE_ATTRIBUTES = cls.STYLE_ATTRIBUTES
514 color: Optional[str] = None
515 bgcolor: Optional[str] = None
516 attributes: Dict[str, Optional[Any]] = {}
517 link: Optional[str] = None
519 words = iter(style_definition.split())
520 for original_word in words:
521 word = original_word.lower()
523 word = next(words, "")
525 raise errors.StyleSyntaxError("color expected after
'on'")
527 Color.parse(word) is None
528 except ColorParseError as error:
529 raise errors.StyleSyntaxError(
530 f"unable to parse {word!r}
as background color
; {error}
"
535 word = next(words, "")
536 attribute = STYLE_ATTRIBUTES.get(word)
537 if attribute is None:
538 raise errors.StyleSyntaxError(
539 f"expected style attribute after
'not', found {word!r}
"
541 attributes[attribute] = False
544 word = next(words, "")
546 raise errors.StyleSyntaxError("URL expected after
'link'")
549 elif word in STYLE_ATTRIBUTES:
550 attributes[STYLE_ATTRIBUTES[word]] = True
555 except ColorParseError as error:
556 raise errors.StyleSyntaxError(
557 f"unable to parse {word!r}
as color
; {error}
"
560 style = Style(color=color, bgcolor=bgcolor, link=link, **attributes)
563 @lru_cache(maxsize=1024)
564 def get_html_style(self, theme: Optional[TerminalTheme] = None) -> str:
565 """Get a CSS style rule."""
566 theme = theme or DEFAULT_TERMINAL_THEME
571 bgcolor = self.bgcolor
573 color, bgcolor = bgcolor, color
576 theme.foreground_color if color is None else color.get_truecolor(theme)
578 color = Color.from_triplet(
579 blend_rgb(foreground_color, theme.background_color, 0.5)
581 if color is not None:
582 theme_color = color.get_truecolor(theme)
583 append(f"color
: {theme_color.hex}
")
584 append(f"text
-decoration
-color
: {theme_color.hex}
")
585 if bgcolor is not None:
586 theme_color = bgcolor.get_truecolor(theme, foreground=False)
587 append(f"background
-color
: {theme_color.hex}
")
589 append("font
-weight
: bold
")
591 append("font
-style
: italic
")
593 append("text
-decoration
: underline
")
595 append("text
-decoration
: line
-through
")
597 append("text
-decoration
: overline
")
598 return "; ".join(css)
601 def combine(cls, styles: Iterable["Style
"]) -> "Style
":
602 """Combine styles and get result.
605 styles (Iterable[Style]): Styles to combine.
608 Style: A new style instance.
610 iter_styles = iter(styles)
611 return sum(iter_styles, next(iter_styles))
614 def chain(cls, *styles: "Style
") -> "Style
":
615 """Combine styles from positional argument in to a single style.
618 *styles (Iterable[Style]): Styles to combine.
621 Style: A new style instance.
623 iter_styles = iter(styles)
624 return sum(iter_styles, next(iter_styles))
626 def copy(self) -> "Style
":
627 """Get a copy of this style.
630 Style: A new Style instance with identical attributes.
634 style: Style = self.__new__(Style)
635 style._ansi = self._ansi
636 style._style_definition = self._style_definition
637 style._color = self._color
638 style._bgcolor = self._bgcolor
639 style._attributes = self._attributes
640 style._set_attributes = self._set_attributes
641 style._link = self._link
642 style._link_id = f"{randint(0, 999999)}
" if self._link else ""
643 style._hash = self._hash
645 style._meta = self._meta
648 @lru_cache(maxsize=128)
649 def clear_meta_and_links(self) -> "Style
":
650 """Get a copy of this style with link and meta information removed.
653 Style: New style object.
657 style: Style = self.__new__(Style)
658 style._ansi = self._ansi
659 style._style_definition = self._style_definition
660 style._color = self._color
661 style._bgcolor = self._bgcolor
662 style._attributes = self._attributes
663 style._set_attributes = self._set_attributes
666 style._hash = self._hash
671 def update_link(self, link: Optional[str] = None) -> "Style
":
672 """Get a copy with a different value for link.
675 link (str, optional): New value for link. Defaults to None.
678 Style: A new Style instance.
680 style: Style = self.__new__(Style)
681 style._ansi = self._ansi
682 style._style_definition = self._style_definition
683 style._color = self._color
684 style._bgcolor = self._bgcolor
685 style._attributes = self._attributes
686 style._set_attributes = self._set_attributes
688 style._link_id = f"{randint(0, 999999)}
" if link else ""
691 style._meta = self._meta
698 color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR,
699 legacy_windows: bool = False,
701 """Render the ANSI codes for the style.
704 text (str, optional): A string to style. Defaults to "".
705 color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR.
708 str: A string containing ANSI style codes.
710 if not text or color_system is None:
712 attrs = self._ansi or self._make_ansi_codes(color_system)
713 rendered = f"\x1b[{attrs}m{text}
\x1b[0m
" if attrs else text
714 if self._link and not legacy_windows:
716 f"\x1b]8;id={self._link_id}
;{self._link}
\x1b\\{rendered}
\x1b]8;;\x1b\\"
720 def test(self, text: Optional[str] = None) -> None:
721 """Write text with style directly to terminal.
723 This method is for testing purposes only.
726 text (Optional[str], optional): Text to style or None for style name.
729 text = text or str(self)
730 sys.stdout.write(f"{self.render(text)}
\n")
732 @lru_cache(maxsize=1024)
733 def _add(self, style: Optional["Style
"]) -> "Style
":
734 if style is None or style._null:
738 new_style: Style = self.__new__(Style)
739 new_style._ansi = None
740 new_style._style_definition = None
741 new_style._color = style._color or self._color
742 new_style._bgcolor = style._bgcolor or self._bgcolor
743 new_style._attributes = (self._attributes & ~style._set_attributes) | (
744 style._attributes & style._set_attributes
746 new_style._set_attributes = self._set_attributes | style._set_attributes
747 new_style._link = style._link or self._link
748 new_style._link_id = style._link_id or self._link_id
749 new_style._null = style._null
750 if self._meta and style._meta:
751 new_style._meta = dumps({**self.meta, **style.meta})
753 new_style._meta = self._meta or style._meta
754 new_style._hash = None
757 def __add__(self, style: Optional["Style
"]) -> "Style
":
758 combined_style = self._add(style)
759 return combined_style.copy() if combined_style.link else combined_style
766 """A stack of styles."""
768 __slots__ = ["_stack
"]
770 def __init__(self, default_style: "Style
") -> None:
771 self._stack: List[Style] = [default_style]
773 def __repr__(self) -> str:
774 return f"<stylestack {self._stack!r}
>"
777 def current(self) -> Style:
778 """Get the Style at the top of the stack."""
779 return self._stack[-1]
781 def push(self, style: Style) -> None:
782 """Push a new style on to the stack.
785 style (Style): New style to combine with current style.
787 self._stack.append(self._stack[-1] + style)
789 def pop(self) -> Style:
790 """Pop last style and discard.
793 Style: New current style (also available as stack.current)
796 return self._stack[-1]