123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785 |
- import sys
- from functools import lru_cache
- from marshal import loads, dumps
- from random import randint
- from typing import Any, cast, Dict, Iterable, List, Optional, Type, Union
- from . import errors
- from .color import Color, ColorParseError, ColorSystem, blend_rgb
- from .repr import rich_repr, Result
- from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme
- # Style instances and style definitions are often interchangeable
- StyleType = Union[str, "Style"]
- class _Bit:
- """A descriptor to get/set a style attribute bit."""
- __slots__ = ["bit"]
- def __init__(self, bit_no: int) -> None:
- self.bit = 1 << bit_no
- def __get__(self, obj: "Style", objtype: Type["Style"]) -> Optional[bool]:
- if obj._set_attributes & self.bit:
- return obj._attributes & self.bit != 0
- return None
- @rich_repr
- class Style:
- """A terminal style.
- A terminal style consists of a color (`color`), a background color (`bgcolor`), and a number of attributes, such
- as bold, italic etc. The attributes have 3 states: they can either be on
- (``True``), off (``False``), or not set (``None``).
- Args:
- color (Union[Color, str], optional): Color of terminal text. Defaults to None.
- bgcolor (Union[Color, str], optional): Color of terminal background. Defaults to None.
- bold (bool, optional): Enable bold text. Defaults to None.
- dim (bool, optional): Enable dim text. Defaults to None.
- italic (bool, optional): Enable italic text. Defaults to None.
- underline (bool, optional): Enable underlined text. Defaults to None.
- blink (bool, optional): Enabled blinking text. Defaults to None.
- blink2 (bool, optional): Enable fast blinking text. Defaults to None.
- reverse (bool, optional): Enabled reverse text. Defaults to None.
- conceal (bool, optional): Enable concealed text. Defaults to None.
- strike (bool, optional): Enable strikethrough text. Defaults to None.
- underline2 (bool, optional): Enable doubly underlined text. Defaults to None.
- frame (bool, optional): Enable framed text. Defaults to None.
- encircle (bool, optional): Enable encircled text. Defaults to None.
- overline (bool, optional): Enable overlined text. Defaults to None.
- link (str, link): Link URL. Defaults to None.
- """
- _color: Optional[Color]
- _bgcolor: Optional[Color]
- _attributes: int
- _set_attributes: int
- _hash: int
- _null: bool
- _meta: Optional[bytes]
- __slots__ = [
- "_color",
- "_bgcolor",
- "_attributes",
- "_set_attributes",
- "_link",
- "_link_id",
- "_ansi",
- "_style_definition",
- "_hash",
- "_null",
- "_meta",
- ]
- # maps bits on to SGR parameter
- _style_map = {
- 0: "1",
- 1: "2",
- 2: "3",
- 3: "4",
- 4: "5",
- 5: "6",
- 6: "7",
- 7: "8",
- 8: "9",
- 9: "21",
- 10: "51",
- 11: "52",
- 12: "53",
- }
- STYLE_ATTRIBUTES = {
- "dim": "dim",
- "d": "dim",
- "bold": "bold",
- "b": "bold",
- "italic": "italic",
- "i": "italic",
- "underline": "underline",
- "u": "underline",
- "blink": "blink",
- "blink2": "blink2",
- "reverse": "reverse",
- "r": "reverse",
- "conceal": "conceal",
- "c": "conceal",
- "strike": "strike",
- "s": "strike",
- "underline2": "underline2",
- "uu": "underline2",
- "frame": "frame",
- "encircle": "encircle",
- "overline": "overline",
- "o": "overline",
- }
- def __init__(
- self,
- *,
- color: Optional[Union[Color, str]] = None,
- bgcolor: Optional[Union[Color, str]] = None,
- bold: Optional[bool] = None,
- dim: Optional[bool] = None,
- italic: Optional[bool] = None,
- underline: Optional[bool] = None,
- blink: Optional[bool] = None,
- blink2: Optional[bool] = None,
- reverse: Optional[bool] = None,
- conceal: Optional[bool] = None,
- strike: Optional[bool] = None,
- underline2: Optional[bool] = None,
- frame: Optional[bool] = None,
- encircle: Optional[bool] = None,
- overline: Optional[bool] = None,
- link: Optional[str] = None,
- meta: Optional[Dict[str, Any]] = None,
- ):
- self._ansi: Optional[str] = None
- self._style_definition: Optional[str] = None
- def _make_color(color: Union[Color, str]) -> Color:
- return color if isinstance(color, Color) else Color.parse(color)
- self._color = None if color is None else _make_color(color)
- self._bgcolor = None if bgcolor is None else _make_color(bgcolor)
- self._set_attributes = sum(
- (
- bold is not None,
- dim is not None and 2,
- italic is not None and 4,
- underline is not None and 8,
- blink is not None and 16,
- blink2 is not None and 32,
- reverse is not None and 64,
- conceal is not None and 128,
- strike is not None and 256,
- underline2 is not None and 512,
- frame is not None and 1024,
- encircle is not None and 2048,
- overline is not None and 4096,
- )
- )
- self._attributes = (
- sum(
- (
- bold and 1 or 0,
- dim and 2 or 0,
- italic and 4 or 0,
- underline and 8 or 0,
- blink and 16 or 0,
- blink2 and 32 or 0,
- reverse and 64 or 0,
- conceal and 128 or 0,
- strike and 256 or 0,
- underline2 and 512 or 0,
- frame and 1024 or 0,
- encircle and 2048 or 0,
- overline and 4096 or 0,
- )
- )
- if self._set_attributes
- else 0
- )
- self._link = link
- self._link_id = f"{randint(0, 999999)}" if link else ""
- self._meta = None if meta is None else dumps(meta)
- self._hash = hash(
- (
- self._color,
- self._bgcolor,
- self._attributes,
- self._set_attributes,
- link,
- self._meta,
- )
- )
- self._null = not (self._set_attributes or color or bgcolor or link or meta)
- @classmethod
- def null(cls) -> "Style":
- """Create an 'null' style, equivalent to Style(), but more performant."""
- return NULL_STYLE
- @classmethod
- def from_color(
- cls, color: Optional[Color] = None, bgcolor: Optional[Color] = None
- ) -> "Style":
- """Create a new style with colors and no attributes.
- Returns:
- color (Optional[Color]): A (foreground) color, or None for no color. Defaults to None.
- bgcolor (Optional[Color]): A (background) color, or None for no color. Defaults to None.
- """
- style: Style = cls.__new__(Style)
- style._ansi = None
- style._style_definition = None
- style._color = color
- style._bgcolor = bgcolor
- style._set_attributes = 0
- style._attributes = 0
- style._link = None
- style._link_id = ""
- style._meta = None
- style._hash = hash(
- (
- color,
- bgcolor,
- None,
- None,
- None,
- None,
- )
- )
- style._null = not (color or bgcolor)
- return style
- @classmethod
- def from_meta(cls, meta: Optional[Dict[str, Any]]) -> "Style":
- """Create a new style with meta data.
- Returns:
- meta (Optional[Dict[str, Any]]): A dictionary of meta data. Defaults to None.
- """
- style: Style = cls.__new__(Style)
- style._ansi = None
- style._style_definition = None
- style._color = None
- style._bgcolor = None
- style._set_attributes = 0
- style._attributes = 0
- style._link = None
- style._link_id = ""
- style._meta = dumps(meta)
- style._hash = hash(
- (
- None,
- None,
- None,
- None,
- None,
- style._meta,
- )
- )
- style._null = not (meta)
- return style
- @classmethod
- def on(cls, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Style":
- """Create a blank style with meta information.
- Example:
- style = Style.on(click=self.on_click)
- Args:
- meta (Optiona[Dict[str, Any]], optional): An optional dict of meta information.
- **handlers (Any): Keyword arguments are translated in to handlers.
- Returns:
- Style: A Style with meta information attached.
- """
- meta = {} if meta is None else meta
- meta.update({f"@{key}": value for key, value in handlers.items()})
- return cls.from_meta(meta)
- bold = _Bit(0)
- dim = _Bit(1)
- italic = _Bit(2)
- underline = _Bit(3)
- blink = _Bit(4)
- blink2 = _Bit(5)
- reverse = _Bit(6)
- conceal = _Bit(7)
- strike = _Bit(8)
- underline2 = _Bit(9)
- frame = _Bit(10)
- encircle = _Bit(11)
- overline = _Bit(12)
- @property
- def link_id(self) -> str:
- """Get a link id, used in ansi code for links."""
- return self._link_id
- def __str__(self) -> str:
- """Re-generate style definition from attributes."""
- if self._style_definition is None:
- attributes: List[str] = []
- append = attributes.append
- bits = self._set_attributes
- if bits & 0b0000000001111:
- if bits & 1:
- append("bold" if self.bold else "not bold")
- if bits & (1 << 1):
- append("dim" if self.dim else "not dim")
- if bits & (1 << 2):
- append("italic" if self.italic else "not italic")
- if bits & (1 << 3):
- append("underline" if self.underline else "not underline")
- if bits & 0b0000111110000:
- if bits & (1 << 4):
- append("blink" if self.blink else "not blink")
- if bits & (1 << 5):
- append("blink2" if self.blink2 else "not blink2")
- if bits & (1 << 6):
- append("reverse" if self.reverse else "not reverse")
- if bits & (1 << 7):
- append("conceal" if self.conceal else "not conceal")
- if bits & (1 << 8):
- append("strike" if self.strike else "not strike")
- if bits & 0b1111000000000:
- if bits & (1 << 9):
- append("underline2" if self.underline2 else "not underline2")
- if bits & (1 << 10):
- append("frame" if self.frame else "not frame")
- if bits & (1 << 11):
- append("encircle" if self.encircle else "not encircle")
- if bits & (1 << 12):
- append("overline" if self.overline else "not overline")
- if self._color is not None:
- append(self._color.name)
- if self._bgcolor is not None:
- append("on")
- append(self._bgcolor.name)
- if self._link:
- append("link")
- append(self._link)
- self._style_definition = " ".join(attributes) or "none"
- return self._style_definition
- def __bool__(self) -> bool:
- """A Style is false if it has no attributes, colors, or links."""
- return not self._null
- def _make_ansi_codes(self, color_system: ColorSystem) -> str:
- """Generate ANSI codes for this style.
- Args:
- color_system (ColorSystem): Color system.
- Returns:
- str: String containing codes.
- """
- if self._ansi is None:
- sgr: List[str] = []
- append = sgr.append
- _style_map = self._style_map
- attributes = self._attributes & self._set_attributes
- if attributes:
- if attributes & 1:
- append(_style_map[0])
- if attributes & 2:
- append(_style_map[1])
- if attributes & 4:
- append(_style_map[2])
- if attributes & 8:
- append(_style_map[3])
- if attributes & 0b0000111110000:
- for bit in range(4, 9):
- if attributes & (1 << bit):
- append(_style_map[bit])
- if attributes & 0b1111000000000:
- for bit in range(9, 13):
- if attributes & (1 << bit):
- append(_style_map[bit])
- if self._color is not None:
- sgr.extend(self._color.downgrade(color_system).get_ansi_codes())
- if self._bgcolor is not None:
- sgr.extend(
- self._bgcolor.downgrade(color_system).get_ansi_codes(
- foreground=False
- )
- )
- self._ansi = ";".join(sgr)
- return self._ansi
- @classmethod
- @lru_cache(maxsize=1024)
- def normalize(cls, style: str) -> str:
- """Normalize a style definition so that styles with the same effect have the same string
- representation.
- Args:
- style (str): A style definition.
- Returns:
- str: Normal form of style definition.
- """
- try:
- return str(cls.parse(style))
- except errors.StyleSyntaxError:
- return style.strip().lower()
- @classmethod
- def pick_first(cls, *values: Optional[StyleType]) -> StyleType:
- """Pick first non-None style."""
- for value in values:
- if value is not None:
- return value
- raise ValueError("expected at least one non-None style")
- def __rich_repr__(self) -> Result:
- yield "color", self.color, None
- yield "bgcolor", self.bgcolor, None
- yield "bold", self.bold, None,
- yield "dim", self.dim, None,
- yield "italic", self.italic, None
- yield "underline", self.underline, None,
- yield "blink", self.blink, None
- yield "blink2", self.blink2, None
- yield "reverse", self.reverse, None
- yield "conceal", self.conceal, None
- yield "strike", self.strike, None
- yield "underline2", self.underline2, None
- yield "frame", self.frame, None
- yield "encircle", self.encircle, None
- yield "link", self.link, None
- if self._meta:
- yield "meta", self.meta
- def __eq__(self, other: Any) -> bool:
- if not isinstance(other, Style):
- return NotImplemented
- return (
- self._color == other._color
- and self._bgcolor == other._bgcolor
- and self._set_attributes == other._set_attributes
- and self._attributes == other._attributes
- and self._link == other._link
- and self._meta == other._meta
- )
- def __hash__(self) -> int:
- return self._hash
- @property
- def color(self) -> Optional[Color]:
- """The foreground color or None if it is not set."""
- return self._color
- @property
- def bgcolor(self) -> Optional[Color]:
- """The background color or None if it is not set."""
- return self._bgcolor
- @property
- def link(self) -> Optional[str]:
- """Link text, if set."""
- return self._link
- @property
- def transparent_background(self) -> bool:
- """Check if the style specified a transparent background."""
- return self.bgcolor is None or self.bgcolor.is_default
- @property
- def background_style(self) -> "Style":
- """A Style with background only."""
- return Style(bgcolor=self.bgcolor)
- @property
- def meta(self) -> Dict[str, Any]:
- """Get meta information (can not be changed after construction)."""
- return {} if self._meta is None else cast(Dict[str, Any], loads(self._meta))
- @property
- def without_color(self) -> "Style":
- """Get a copy of the style with color removed."""
- if self._null:
- return NULL_STYLE
- style: Style = self.__new__(Style)
- style._ansi = None
- style._style_definition = None
- style._color = None
- style._bgcolor = None
- style._attributes = self._attributes
- style._set_attributes = self._set_attributes
- style._link = self._link
- style._link_id = f"{randint(0, 999999)}" if self._link else ""
- style._hash = self._hash
- style._null = False
- style._meta = None
- return style
- @classmethod
- @lru_cache(maxsize=4096)
- def parse(cls, style_definition: str) -> "Style":
- """Parse a style definition.
- Args:
- style_definition (str): A string containing a style.
- Raises:
- errors.StyleSyntaxError: If the style definition syntax is invalid.
- Returns:
- `Style`: A Style instance.
- """
- if style_definition.strip() == "none" or not style_definition:
- return cls.null()
- STYLE_ATTRIBUTES = cls.STYLE_ATTRIBUTES
- color: Optional[str] = None
- bgcolor: Optional[str] = None
- attributes: Dict[str, Optional[Any]] = {}
- link: Optional[str] = None
- words = iter(style_definition.split())
- for original_word in words:
- word = original_word.lower()
- if word == "on":
- word = next(words, "")
- if not word:
- raise errors.StyleSyntaxError("color expected after 'on'")
- try:
- Color.parse(word) is None
- except ColorParseError as error:
- raise errors.StyleSyntaxError(
- f"unable to parse {word!r} as background color; {error}"
- ) from None
- bgcolor = word
- elif word == "not":
- word = next(words, "")
- attribute = STYLE_ATTRIBUTES.get(word)
- if attribute is None:
- raise errors.StyleSyntaxError(
- f"expected style attribute after 'not', found {word!r}"
- )
- attributes[attribute] = False
- elif word == "link":
- word = next(words, "")
- if not word:
- raise errors.StyleSyntaxError("URL expected after 'link'")
- link = word
- elif word in STYLE_ATTRIBUTES:
- attributes[STYLE_ATTRIBUTES[word]] = True
- else:
- try:
- Color.parse(word)
- except ColorParseError as error:
- raise errors.StyleSyntaxError(
- f"unable to parse {word!r} as color; {error}"
- ) from None
- color = word
- style = Style(color=color, bgcolor=bgcolor, link=link, **attributes)
- return style
- @lru_cache(maxsize=1024)
- def get_html_style(self, theme: Optional[TerminalTheme] = None) -> str:
- """Get a CSS style rule."""
- theme = theme or DEFAULT_TERMINAL_THEME
- css: List[str] = []
- append = css.append
- color = self.color
- bgcolor = self.bgcolor
- if self.reverse:
- color, bgcolor = bgcolor, color
- if self.dim:
- foreground_color = (
- theme.foreground_color if color is None else color.get_truecolor(theme)
- )
- color = Color.from_triplet(
- blend_rgb(foreground_color, theme.background_color, 0.5)
- )
- if color is not None:
- theme_color = color.get_truecolor(theme)
- append(f"color: {theme_color.hex}")
- append(f"text-decoration-color: {theme_color.hex}")
- if bgcolor is not None:
- theme_color = bgcolor.get_truecolor(theme, foreground=False)
- append(f"background-color: {theme_color.hex}")
- if self.bold:
- append("font-weight: bold")
- if self.italic:
- append("font-style: italic")
- if self.underline:
- append("text-decoration: underline")
- if self.strike:
- append("text-decoration: line-through")
- if self.overline:
- append("text-decoration: overline")
- return "; ".join(css)
- @classmethod
- def combine(cls, styles: Iterable["Style"]) -> "Style":
- """Combine styles and get result.
- Args:
- styles (Iterable[Style]): Styles to combine.
- Returns:
- Style: A new style instance.
- """
- iter_styles = iter(styles)
- return sum(iter_styles, next(iter_styles))
- @classmethod
- def chain(cls, *styles: "Style") -> "Style":
- """Combine styles from positional argument in to a single style.
- Args:
- *styles (Iterable[Style]): Styles to combine.
- Returns:
- Style: A new style instance.
- """
- iter_styles = iter(styles)
- return sum(iter_styles, next(iter_styles))
- def copy(self) -> "Style":
- """Get a copy of this style.
- Returns:
- Style: A new Style instance with identical attributes.
- """
- if self._null:
- return NULL_STYLE
- style: Style = self.__new__(Style)
- style._ansi = self._ansi
- style._style_definition = self._style_definition
- style._color = self._color
- style._bgcolor = self._bgcolor
- style._attributes = self._attributes
- style._set_attributes = self._set_attributes
- style._link = self._link
- style._link_id = f"{randint(0, 999999)}" if self._link else ""
- style._hash = self._hash
- style._null = False
- style._meta = self._meta
- return style
- def update_link(self, link: Optional[str] = None) -> "Style":
- """Get a copy with a different value for link.
- Args:
- link (str, optional): New value for link. Defaults to None.
- Returns:
- Style: A new Style instance.
- """
- style: Style = self.__new__(Style)
- style._ansi = self._ansi
- style._style_definition = self._style_definition
- style._color = self._color
- style._bgcolor = self._bgcolor
- style._attributes = self._attributes
- style._set_attributes = self._set_attributes
- style._link = link
- style._link_id = f"{randint(0, 999999)}" if link else ""
- style._hash = self._hash
- style._null = False
- style._meta = self._meta
- return style
- def render(
- self,
- text: str = "",
- *,
- color_system: Optional[ColorSystem] = ColorSystem.TRUECOLOR,
- legacy_windows: bool = False,
- ) -> str:
- """Render the ANSI codes for the style.
- Args:
- text (str, optional): A string to style. Defaults to "".
- color_system (Optional[ColorSystem], optional): Color system to render to. Defaults to ColorSystem.TRUECOLOR.
- Returns:
- str: A string containing ANSI style codes.
- """
- if not text or color_system is None:
- return text
- attrs = self._make_ansi_codes(color_system)
- rendered = f"\x1b[{attrs}m{text}\x1b[0m" if attrs else text
- if self._link and not legacy_windows:
- rendered = (
- f"\x1b]8;id={self._link_id};{self._link}\x1b\\{rendered}\x1b]8;;\x1b\\"
- )
- return rendered
- def test(self, text: Optional[str] = None) -> None:
- """Write text with style directly to terminal.
- This method is for testing purposes only.
- Args:
- text (Optional[str], optional): Text to style or None for style name.
- """
- text = text or str(self)
- sys.stdout.write(f"{self.render(text)}\n")
- def __add__(self, style: Optional["Style"]) -> "Style":
- if not (isinstance(style, Style) or style is None):
- return NotImplemented
- if style is None or style._null:
- return self
- if self._null:
- return style
- new_style: Style = self.__new__(Style)
- new_style._ansi = None
- new_style._style_definition = None
- new_style._color = style._color or self._color
- new_style._bgcolor = style._bgcolor or self._bgcolor
- new_style._attributes = (self._attributes & ~style._set_attributes) | (
- style._attributes & style._set_attributes
- )
- new_style._set_attributes = self._set_attributes | style._set_attributes
- new_style._link = style._link or self._link
- new_style._link_id = style._link_id or self._link_id
- new_style._hash = style._hash
- new_style._null = self._null or style._null
- if self._meta and style._meta:
- new_style._meta = dumps({**self.meta, **style.meta})
- else:
- new_style._meta = self._meta or style._meta
- return new_style
- NULL_STYLE = Style()
- class StyleStack:
- """A stack of styles."""
- __slots__ = ["_stack"]
- def __init__(self, default_style: "Style") -> None:
- self._stack: List[Style] = [default_style]
- def __repr__(self) -> str:
- return f"<stylestack {self._stack!r}>"
- @property
- def current(self) -> Style:
- """Get the Style at the top of the stack."""
- return self._stack[-1]
- def push(self, style: Style) -> None:
- """Push a new style on to the stack.
- Args:
- style (Style): New style to combine with current style.
- """
- self._stack.append(self._stack[-1] + style)
- def pop(self) -> Style:
- """Pop last style and discard.
- Returns:
- Style: New current style (also available as stack.current)
- """
- self._stack.pop()
- return self._stack[-1]
|