import sys
from functools import lru_cache
from marshal import dumps, loads
from random import randint
from typing import Any, Dict, Iterable, List, Optional, Type, Union, cast
from . import errors
from .color import Color, ColorParseError, ColorSystem, blend_rgb
from .repr import Result, rich_repr
from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme
StyleType = Union[str, "Style"]
class _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:
_color: Optional[Color]
_bgcolor: Optional[Color]
_attributes: int
_set_attributes: int
_hash: Optional[int]
_null: bool
_meta: Optional[bytes]
__slots__ = [
"_color",
"_bgcolor",
"_attributes",
"_set_attributes",
"_link",
"_link_id",
"_ansi",
"_style_definition",
"_hash",
"_null",
"_meta",
]
_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._meta = None if meta is None else dumps(meta)
self._link_id = (
f"{randint(0, 999999)}{hash(self._meta)}" if (link or meta) else ""
)
self._hash: Optional[int] = None
self._null = not (self._set_attributes or color or bgcolor or link or meta)
@classmethod
def null(cls) -> "Style":
return NULL_STYLE
@classmethod
def from_color(
cls, color: Optional[Color] = None, bgcolor: Optional[Color] = None
) -> "Style":
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._null = not (color or bgcolor)
style._hash = None
return style
@classmethod
def from_meta(cls, meta: Optional[Dict[str, Any]]) -> "Style":
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._meta = dumps(meta)
style._link_id = f"{randint(0, 999999)}{hash(style._meta)}"
style._hash = None
style._null = not (meta)
return style
@classmethod
def on(cls, meta: Optional[Dict[str, Any]] = None, **handlers: Any) -> "Style":
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:
return self._link_id
def __str__(self) -> str:
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:
return not self._null
def _make_ansi_codes(self, color_system: ColorSystem) -> str:
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:
try:
return str(cls.parse(style))
except errors.StyleSyntaxError:
return style.strip().lower()
@classmethod
def pick_first(cls, *values: Optional[StyleType]) -> StyleType:
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.__hash__() == other.__hash__()
def __ne__(self, other: Any) -> bool:
if not isinstance(other, Style):
return NotImplemented
return self.__hash__() != other.__hash__()
def __hash__(self) -> int:
if self._hash is not None:
return self._hash
self._hash = hash(
(
self._color,
self._bgcolor,
self._attributes,
self._set_attributes,
self._link,
self._meta,
)
)
return self._hash
@property
def color(self) -> Optional[Color]:
return self._color
@property
def bgcolor(self) -> Optional[Color]:
return self._bgcolor
@property
def link(self) -> Optional[str]:
return self._link
@property
def transparent_background(self) -> bool:
return self.bgcolor is None or self.bgcolor.is_default
@property
def background_style(self) -> "Style":
return Style(bgcolor=self.bgcolor)
@property
def meta(self) -> Dict[str, Any]:
return {} if self._meta is None else cast(Dict[str, Any], loads(self._meta))
@property
def without_color(self) -> "Style":
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._null = False
style._meta = None
style._hash = None
return style
@classmethod
@lru_cache(maxsize=4096)
def parse(cls, style_definition: str) -> "Style":
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:
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":
iter_styles = iter(styles)
return sum(iter_styles, next(iter_styles))
@classmethod
def chain(cls, *styles: "Style") -> "Style":
iter_styles = iter(styles)
return sum(iter_styles, next(iter_styles))
def copy(self) -> "Style":
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
@lru_cache(maxsize=128)
def clear_meta_and_links(self) -> "Style":
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 = None
style._link_id = ""
style._hash = self._hash
style._null = False
style._meta = None
return style
def update_link(self, link: Optional[str] = None) -> "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 = link
style._link_id = f"{randint(0, 999999)}" if link else ""
style._hash = None
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:
if not text or color_system is None:
return text
attrs = self._ansi or 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:
text = text or str(self)
sys.stdout.write(f"{self.render(text)}\n")
@lru_cache(maxsize=1024)
def _add(self, style: Optional["Style"]) -> "Style":
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._null = 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
new_style._hash = None
return new_style
def __add__(self, style: Optional["Style"]) -> "Style":
combined_style = self._add(style)
return combined_style.copy() if combined_style.link else combined_style
NULL_STYLE = Style()
class StyleStack:
__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:
return self._stack[-1]
def push(self, style: Style) -> None:
self._stack.append(self._stack[-1] + style)
def pop(self) -> Style:
self._stack.pop()
return self._stack[-1]