mirror of https://github.com/morpheus65535/bazarr
696 lines
23 KiB
Python
696 lines
23 KiB
Python
import sys
|
|
from functools import lru_cache
|
|
from random import randint
|
|
from time import time
|
|
from typing import Any, Dict, Iterable, List, Optional, Type, Union
|
|
|
|
from . import errors
|
|
from .color import Color, ColorParseError, ColorSystem, blend_rgb
|
|
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
|
|
|
|
|
|
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
|
|
|
|
__slots__ = [
|
|
"_color",
|
|
"_bgcolor",
|
|
"_attributes",
|
|
"_set_attributes",
|
|
"_link",
|
|
"_link_id",
|
|
"_ansi",
|
|
"_style_definition",
|
|
"_hash",
|
|
"_null",
|
|
]
|
|
|
|
# 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",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
color: Union[Color, str] = None,
|
|
bgcolor: Union[Color, str] = None,
|
|
bold: bool = None,
|
|
dim: bool = None,
|
|
italic: bool = None,
|
|
underline: bool = None,
|
|
blink: bool = None,
|
|
blink2: bool = None,
|
|
reverse: bool = None,
|
|
conceal: bool = None,
|
|
strike: bool = None,
|
|
underline2: bool = None,
|
|
frame: bool = None,
|
|
encircle: bool = None,
|
|
overline: bool = None,
|
|
link: str = 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"{time()}-{randint(0, 999999)}" if link else ""
|
|
self._hash = hash(
|
|
(
|
|
self._color,
|
|
self._bgcolor,
|
|
self._attributes,
|
|
self._set_attributes,
|
|
link,
|
|
)
|
|
)
|
|
self._null = not (self._set_attributes or color or bgcolor or link)
|
|
|
|
@classmethod
|
|
def null(cls) -> "Style":
|
|
"""Create an 'null' style, equivalent to Style(), but more performant."""
|
|
return NULL_STYLE
|
|
|
|
@classmethod
|
|
def from_color(cls, color: Color = None, bgcolor: 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 = 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._hash = hash(
|
|
(
|
|
color,
|
|
bgcolor,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
)
|
|
style._null = not (color or bgcolor)
|
|
return style
|
|
|
|
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 __repr__(self) -> str:
|
|
"""Render a named style differently from an anonymous style."""
|
|
return f'Style.parse("{self}")'
|
|
|
|
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
|
|
)
|
|
|
|
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 without_color(self) -> "Style":
|
|
"""Get a copy of the style with color removed."""
|
|
if self._null:
|
|
return NULL_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"{time()}-{randint(0, 999999)}" if self._link else ""
|
|
style._hash = self._hash
|
|
style._null = False
|
|
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 = {
|
|
"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",
|
|
}
|
|
color: Optional[str] = None
|
|
bgcolor: Optional[str] = None
|
|
attributes: Dict[str, Optional[bool]] = {}
|
|
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: 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 = 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"{time()}-{randint(0, 999999)}" if self._link else ""
|
|
style._hash = self._hash
|
|
style._null = False
|
|
return style
|
|
|
|
def update_link(self, link: 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 = 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"{time()}-{randint(0, 999999)}" if link else ""
|
|
style._hash = self._hash
|
|
style._null = False
|
|
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 # type: ignore
|
|
if style is None or style._null:
|
|
return self
|
|
if self._null:
|
|
return style
|
|
new_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
|
|
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]
|