ai-station/.venv/lib/python3.12/site-packages/textual/style.py

538 lines
16 KiB
Python
Raw Normal View History

2025-12-25 14:54:33 +00:00
"""
The Style class contains all the information needed to generate styled terminal output.
You won't often need to create Style objects directly, if you are using [Content][textual.content.Content] for output.
But you might want to use styles for more customized widgets.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property, lru_cache
from operator import attrgetter
from pickle import dumps, loads
from typing import TYPE_CHECKING, Any, Iterable, Mapping
import rich.repr
from rich.style import Style as RichStyle
from rich.terminal_theme import TerminalTheme
from textual._context import active_app
from textual.color import Color
if TYPE_CHECKING:
from textual.css.styles import StylesBase
_get_hash_attributes = attrgetter(
"background",
"foreground",
"bold",
"dim",
"italic",
"underline",
"underline2",
"reverse",
"strike",
"blink",
"link",
"auto_color",
"_meta",
)
_get_simple_attributes = attrgetter(
"background",
"foreground",
"bold",
"dim",
"italic",
"underline",
"underline2",
"reverse",
"strike",
"blink",
"link",
"_meta",
)
_get_simple_attributes_sans_color = attrgetter(
"bold",
"dim",
"italic",
"underline",
"underline2",
"reverse",
"strike",
"blink",
"link",
"_meta",
)
_get_attributes = attrgetter(
"background",
"foreground",
"bold",
"dim",
"italic",
"underline",
"underline2",
"reverse",
"strike",
"blink",
"link",
"meta",
"_meta",
)
@rich.repr.auto()
@dataclass(frozen=True)
class Style:
"""Represents a style in the Visual interface (color and other attributes).
Styles may be added together, which combines their style attributes.
"""
background: Color | None = None
foreground: Color | None = None
bold: bool | None = None
dim: bool | None = None
italic: bool | None = None
underline: bool | None = None
underline2: bool | None = None
reverse: bool | None = None
strike: bool | None = None
blink: bool | None = None
link: str | None = None
_meta: bytes | None = None
auto_color: bool = False
def __rich_repr__(self) -> rich.repr.Result:
yield "background", self.background, None
yield "foreground", self.foreground, None
yield "bold", self.bold, None
yield "dim", self.dim, None
yield "italic", self.italic, None
yield "underline", self.underline, None
yield "underline2", self.underline2, None
yield "reverse", self.reverse, None
yield "strike", self.strike, None
yield "blink", self.blink, None
yield "link", self.link, None
if self._meta is not None:
yield "meta", self.meta
@cached_property
def _is_null(self) -> bool:
return _get_simple_attributes(self) == (
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
@cached_property
def hash(self) -> int:
"""A hash of the style's attributes."""
return hash(_get_hash_attributes(self))
def __hash__(self) -> int:
return self.hash
def __eq__(self, other: Any) -> bool:
if not isinstance(other, Style):
return NotImplemented
return self.hash == other.hash
def __bool__(self) -> bool:
return not self._is_null
def __str__(self) -> str:
return self.style_definition
@cached_property
def style_definition(self) -> str:
"""Style encoded in a string (may be parsed from `Style.parse`)."""
output: list[str] = []
output_append = output.append
if self.foreground is not None:
output_append(self.foreground.css)
if self.background is not None:
output_append(f"on {self.background.css}")
if self.bold is not None:
output_append("bold" if self.bold else "not bold")
if self.dim is not None:
output_append("dim" if self.dim else "not dim")
if self.italic is not None:
output_append("italic" if self.italic else "not italic")
if self.underline is not None:
output_append("underline" if self.underline else "not underline")
if self.underline2 is not None:
output_append("underline2" if self.underline2 else "not underline2")
if self.strike is not None:
output_append("strike" if self.strike else "not strike")
if self.blink is not None:
output_append("blink" if self.blink else "not blink")
if self.link is not None:
if "'" not in self.link:
output_append(f"link='{self.link}'")
elif '"' not in self.link:
output_append(f'link="{self.link}"')
if self._meta is not None:
for key, value in self.meta.items():
if isinstance(value, str):
if "'" not in key:
output_append(f"{key}='{value}'")
elif '"' not in key:
output_append(f'{key}="{value}"')
else:
output_append(f"{key}={value!r}")
else:
output_append(f"{key}={value!r}")
return " ".join(output)
@cached_property
def markup_tag(self) -> str:
"""Identifier used to close tags in markup."""
output: list[str] = []
output_append = output.append
if self.foreground is not None:
output_append(self.foreground.css)
if self.background is not None:
output_append(f"on {self.background.css}")
if self.bold is not None:
output_append("bold" if self.bold else "not bold")
if self.dim is not None:
output_append("dim" if self.dim else "not dim")
if self.italic is not None:
output_append("italic" if self.italic else "not italic")
if self.underline is not None:
output_append("underline" if self.underline else "not underline")
if self.underline2 is not None:
output_append("underline2" if self.underline2 else "not underline2")
if self.strike is not None:
output_append("strike" if self.strike else "not strike")
if self.blink is not None:
output_append("blink" if self.blink else "not blink")
if self.link is not None:
output_append("link")
if self._meta is not None:
for key, value in self.meta.items():
if isinstance(value, str):
output_append(f"{key}=")
return " ".join(output)
@lru_cache(maxsize=1024 * 4)
def __add__(self, other: object | None) -> Style:
if isinstance(other, Style):
if self._is_null:
return other
if other._is_null:
return self
(
background,
foreground,
bold,
dim,
italic,
underline,
underline2,
reverse,
strike,
blink,
link,
meta,
_meta,
) = _get_attributes(self)
(
other_background,
other_foreground,
other_bold,
other_dim,
other_italic,
other_underline,
other_underline2,
other_reverse,
other_strike,
other_blink,
other_link,
other_meta,
other__meta,
) = _get_attributes(other)
new_style = Style(
(
other_background
if (background is None or background.a == 0)
else background + other_background
),
(
foreground
if (other_foreground is None or other_foreground.a == 0)
else other_foreground
),
bold if other_bold is None else other_bold,
dim if other_dim is None else other_dim,
italic if other_italic is None else other_italic,
underline if other_underline is None else other_underline,
underline2 if other_underline2 is None else other_underline2,
reverse if other_reverse is None else other_reverse,
strike if other_strike is None else other_strike,
blink if other_blink is None else other_blink,
link if other_link is None else other_link,
(
dumps({**meta, **other_meta})
if _meta is not None and other__meta is not None
else (_meta if other__meta is None else other__meta)
),
)
return new_style
elif other is None:
return self
else:
return NotImplemented
__radd__ = __add__
@classmethod
def null(cls) -> Style:
"""Get a null (no color or style) style."""
return NULL_STYLE
@classmethod
def parse(cls, text_style: str, variables: dict[str, str] | None = None) -> Style:
"""Parse a style from text.
Args:
text_style: A style encoded in a string.
variables: Optional mapping of CSS variables. `None` to get variables from the app.
Returns:
New style.
"""
from textual.markup import parse_style
try:
app = active_app.get()
except LookupError:
return parse_style(text_style, variables)
return app.stylesheet.parse_style(text_style)
@classmethod
def _normalize_markup_tag(cls, text_style: str) -> str:
"""Produces a normalized from of a style, used to match closing tags with opening tags.
Args:
text_style: Style to normalize.
Returns:
Normalized markup tag.
"""
try:
style = cls.parse(text_style)
except Exception:
return text_style.strip()
return style.markup_tag
@classmethod
def from_rich_style(
cls, rich_style: RichStyle, theme: TerminalTheme | None = None
) -> Style:
"""Build a Style from a (Rich) Style.
Args:
rich_style: A Rich Style object.
theme: Optional Rich [terminal theme][rich.terminal_theme.TerminalTheme].
Returns:
New Style.
"""
return Style(
(
None
if rich_style.bgcolor is None
else Color.from_rich_color(rich_style.bgcolor, theme)
),
(
None
if rich_style.color is None
else Color.from_rich_color(rich_style.color, theme)
),
bold=rich_style.bold,
dim=rich_style.dim,
italic=rich_style.italic,
underline=rich_style.underline,
underline2=rich_style.underline2,
reverse=rich_style.reverse,
strike=rich_style.strike,
blink=rich_style.blink,
link=rich_style.link,
_meta=rich_style._meta,
)
@classmethod
def from_styles(cls, styles: StylesBase) -> Style:
"""Create a Visual Style from a Textual styles object.
Args:
styles: A Styles object, such as `my_widget.styles`.
"""
text_style = styles.text_style
return Style(
styles.background,
(
Color(0, 0, 0, styles.color.a, auto=True)
if styles.auto_color
else styles.color
),
bold=text_style.bold,
dim=text_style.italic,
italic=text_style.italic,
underline=text_style.underline,
underline2=text_style.underline2,
reverse=text_style.reverse,
strike=text_style.strike,
blink=text_style.blink,
auto_color=styles.auto_color,
)
@classmethod
def from_meta(cls, meta: Mapping[str, Any]) -> Style:
"""Create a Visual Style containing meta information.
Args:
meta: A dictionary of meta information.
Returns:
A new Style.
"""
return Style(_meta=dumps({**meta}))
@cached_property
def rich_style(self) -> RichStyle:
"""Convert this Styles into a Rich style.
Returns:
A Rich style object.
"""
(
background,
foreground,
bold,
dim,
italic,
underline,
underline2,
reverse,
strike,
blink,
link,
_meta,
) = _get_simple_attributes(self)
color = None if foreground is None else background + foreground
return RichStyle(
color=None if color is None else color.rich_color,
bgcolor=None if background is None else background.rich_color,
bold=bold,
dim=dim,
italic=italic,
underline=underline,
underline2=underline2,
reverse=reverse,
strike=strike,
blink=blink,
link=link,
meta=None if _meta is None else self.meta,
)
def rich_style_with_offset(self, x: int, y: int) -> RichStyle:
"""Get a Rich style with the given offset included in meta.
This is used in text selection.
Args:
x: X coordinate.
y: Y coordinate.
Returns:
A Rich Style object.
"""
(
background,
foreground,
bold,
dim,
italic,
underline,
underline2,
reverse,
strike,
blink,
link,
_meta,
) = _get_simple_attributes(self)
color = None if foreground is None else background + foreground
return RichStyle(
color=None if color is None else color.rich_color,
bgcolor=None if background is None else background.rich_color,
bold=bold,
dim=dim,
italic=italic,
underline=underline,
underline2=underline2,
reverse=reverse,
strike=strike,
blink=blink,
link=link,
meta={**self.meta, "offset": (x, y)},
)
@cached_property
def without_color(self) -> Style:
"""The style without any colors."""
return Style(None, None, *_get_simple_attributes_sans_color(self))
@cached_property
def background_style(self) -> Style:
"""Just the background color, with no other attributes."""
return Style(self.background, _meta=self._meta)
@property
def has_transparent_foreground(self) -> bool:
"""Is the foreground transparent (or not set)?"""
return self.foreground is None or self.foreground.a == 0
@classmethod
def combine(cls, styles: Iterable[Style]) -> Style:
"""Add a number of styles and get the result."""
iter_styles = iter(styles)
return sum(iter_styles, next(iter_styles))
@cached_property
def meta(self) -> Mapping[str, Any]:
"""Get meta information (can not be changed after construction)."""
return {} if self._meta is None else loads(self._meta)
NULL_STYLE = Style()