diff --git a/src/textual/app.py b/src/textual/app.py index 16ed1acdec..c38d0b6dcd 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -123,6 +123,7 @@ ) from textual.signal import Signal from textual.timer import Timer +from textual.visual import SupportsTextualize from textual.widget import AwaitMount, Widget from textual.widgets._toast import ToastRack from textual.worker import NoActiveWorker, get_current_worker @@ -188,7 +189,7 @@ } ComposeResult = Iterable[Widget] -RenderResult = RenderableType +RenderResult = RenderableType | SupportsTextualize AutopilotCallbackType: TypeAlias = ( "Callable[[Pilot[object]], Coroutine[Any, Any, None]]" diff --git a/src/textual/content.py b/src/textual/content.py index 9f210706df..ad5fbf7779 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -11,23 +11,21 @@ from __future__ import annotations import re -from dataclasses import dataclass -from functools import cached_property, lru_cache from itertools import zip_longest -from marshal import loads from operator import itemgetter -from typing import Any, Iterable, NamedTuple, Sequence, cast +from typing import Callable, Iterable, NamedTuple, Sequence import rich.repr from rich._wrap import divide_line from rich.cells import set_cell_size from rich.console import JustifyMethod, OverflowMethod from rich.segment import Segment, Segments -from rich.style import Style as RichStyle from textual._cells import cell_len from textual._loop import loop_last -from textual.color import TRANSPARENT, Color +from textual.color import Color +from textual.strip import Strip +from textual.visual import Style, Visual _re_whitespace = re.compile(r"\s+$") @@ -99,95 +97,19 @@ def _justify_lines( return lines -@rich.repr.auto -@dataclass(frozen=True) -class Style: - """Represent a content style (color and other attributes).""" - - background: Color = TRANSPARENT - foreground: Color = TRANSPARENT - bold: bool | None = None - dim: bool | None = None - italic: bool | None = None - underline: bool | None = None - strike: bool | None = None - link: str | None = None - _meta: bytes | None = None - - def __rich_repr__(self) -> rich.repr.Result: - yield None, self.background - yield None, self.foreground - yield "bold", self.bold, None - yield "dim", self.dim, None - yield "italic", self.italic, None - yield "underline", self.underline, None - yield "strike", self.strike, None - - @lru_cache(maxsize=1024) - def __add__(self, other: object) -> Style: - if not isinstance(other, Style): - return NotImplemented - new_style = Style( - self.background + other.background, - self.foreground if other.foreground.is_transparent else other.foreground, - self.bold if other.bold is None else other.bold, - self.dim if other.dim is None else other.dim, - self.italic if other.italic is None else other.italic, - self.underline if other.underline is None else other.underline, - self.strike if other.strike is None else other.strike, - self.link if other.link is None else other.link, - self._meta if other._meta is None else other._meta, - ) - return new_style - - @cached_property - def rich_style(self) -> RichStyle: - return RichStyle( - color=(self.background + self.foreground).rich_color, - bgcolor=self.background.rich_color, - bold=self.bold, - dim=self.dim, - italic=self.italic, - underline=self.underline, - strike=self.strike, - link=self.link, - meta=self.meta, - ) - - @cached_property - def without_color(self) -> Style: - return Style( - bold=self.bold, - dim=self.dim, - italic=self.italic, - strike=self.strike, - link=self.link, - _meta=self._meta, - ) - - @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)) - - @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)) - - ANSI_DEFAULT = Style( background=Color(0, 0, 0, 0, ansi=-1), foreground=Color(0, 0, 0, 0, ansi=-1) ) +TRANSPARENT_STYLE = Style() + class Span(NamedTuple): """A style applied to a range of character offsets.""" start: int end: int - style: Style + style: Style | str def __rich_repr__(self) -> rich.repr.Result: yield self.start @@ -211,7 +133,7 @@ def extend(self, cells: int) -> "Span": @rich.repr.auto -class Content: +class Content(Visual): """Text content with marked up spans. This object can be considered immutable, although it might update its internal state @@ -219,20 +141,72 @@ class Content: """ + __slots__ = ["_text", "_spans", "_cell_length"] + def __init__( - self, text: str, spans: list[Span] | None = None, cell_length: int | None = None + self, + text: str, + spans: list[Span] | None = None, + cell_length: int | None = None, ) -> None: self._text: str = text self._spans: list[Span] = [] if spans is None else spans - assert isinstance(self._spans, list) - assert all(isinstance(span, Span) for span in self._spans) self._cell_length = cell_length + @classmethod + def styled_text( + cls, text: str, style: Style | str = "", cell_length: int | None = None + ) -> Content: + if not text: + return Content("") + span_length = cell_len(text) if cell_length is None else cell_length + new_content = cls(text, [Span(0, span_length, style)], span_length) + return new_content + + def get_optimal_width(self, tab_size: int = 8) -> int: + lines = self.without_spans.split("\n") + return max(line.expand_tabs(tab_size).cell_length for line in lines) + + def get_minimal_width(self, tab_size: int = 8) -> int: + lines = self.without_spans.split("\n") + return max([cell_len(word) for line in lines for word in line.plain.split()]) + + def textualize(self) -> Content: + return self + + def render_strips( + self, + width: int, + *, + height: int | None, + base_style: Style = Style(), + justify: JustifyMethod = "left", + overflow: OverflowMethod = "fold", + no_wrap: bool = False, + tab_size: int = 8, + ) -> list[Strip]: + lines = self.wrap( + width, + justify=justify, + overflow=overflow, + no_wrap=no_wrap, + tab_size=tab_size, + ) + if height is not None: + lines = lines[:height] + return [ + Strip(line.render_segments(base_style), line.cell_length) for line in lines + ] + + def get_height(self, width: int) -> int: + lines = self.wrap(width) + return len(lines) + def __len__(self) -> int: return len(self.plain) def __bool__(self) -> bool: - return self._text == [] + return self._text == "" def __hash__(self) -> int: return hash(self._text) @@ -253,6 +227,10 @@ def plain(self) -> str: """Get the text as a single string.""" return self._text + @property + def without_spans(self) -> Content: + return Content(self.plain, [], self._cell_length) + def __getitem__(self, slice: int | slice) -> Content: def get_text_at(offset: int) -> "Content": _Span = Span @@ -317,11 +295,47 @@ def _trim_spans(cls, text: str, spans: list[Span]) -> list[Span]: ] return spans + def append(self, content: Content | str) -> Content: + """Append text or content to this content. + + Note this is a little inefficient, if you have many strings to append, consider [`join`][textual.content.Content.join]. + + Args: + content: A content instance, or a string. + + Returns: + New content. + """ + if isinstance(content, str): + return Content( + self.plain, + self._spans, + ( + None + if self._cell_length is None + else self._cell_length + cell_len(content) + ), + ) + return Content("").join([self, content]) + + def append_text(self, text: str, style: Style | str = "") -> Content: + return self.append(Content.styled_text(text, style)) + def join(self, lines: Iterable[Content]) -> Content: + """Join an iterable of content. + + Args: + lines (_type_): An iterable of content instances. + + Returns: + A single Content instance, containing all of the lines. + + """ text: list[str] = [] spans: list[Span] = [] def iter_content() -> Iterable[Content]: + """Iterate the lines, optionally inserting the separator.""" if self.plain: for last, line in loop_last(lines): yield line @@ -335,6 +349,8 @@ def iter_content() -> Iterable[Content]: offset = 0 _Span = Span + total_cell_length: int | None = self._cell_length + for content in iter_content(): extend_text(content._text) extend_spans( @@ -342,7 +358,14 @@ def iter_content() -> Iterable[Content]: for start, end, style in content._spans ) offset += len(content._text) - return Content("".join(text), spans, offset) + if total_cell_length is not None: + total_cell_length = ( + None + if content._cell_length is None + else total_cell_length + content._cell_length + ) + + return Content("".join(text), spans, total_cell_length) def get_style_at_offset(self, offset: int) -> Style: """Get the style of a character at give offset. @@ -402,7 +425,11 @@ def pad_left(self, count: int, character: str = " ") -> Content: _Span(start + count, end + count, style) for start, end, style in self._spans ] - return Content(text, spans) + return Content( + text, + spans, + None if self._cell_length is None else self._cell_length + count, + ) return self def pad_right(self, count: int, character: str = " ") -> Content: @@ -414,7 +441,11 @@ def pad_right(self, count: int, character: str = " ") -> Content: """ assert len(character) == 1, "Character must be a string of length 1" if count: - return Content(f"{self.plain}{character * count}", self._spans) + return Content( + f"{self.plain}{character * count}", + self._spans, + None if self._cell_length is None else self._cell_length + count, + ) return self def center(self, width: int, overflow: OverflowMethod = "fold") -> Content: @@ -445,7 +476,9 @@ def right_crop(self, amount: int = 1) -> Content: length = None if self._cell_length is None else self._cell_length - amount return Content(text, spans, length) - def stylize(self, style: Style, start: int = 0, end: int | None = None) -> Content: + def stylize( + self, style: Style | str, start: int = 0, end: int | None = None + ) -> Content: """Apply a style to the text, or a portion of the text. Args: @@ -453,6 +486,8 @@ def stylize(self, style: Style, start: int = 0, end: int | None = None) -> Conte start (int): Start offset (negative indexing is supported). Defaults to 0. end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. """ + if not style: + return self length = len(self) if start < 0: start = length + start @@ -468,15 +503,63 @@ def stylize(self, style: Style, start: int = 0, end: int | None = None) -> Conte [*self._spans, Span(start, length if length < end else end, style)], ) - def render(self, base_style: Style, end: str = "\n") -> Iterable[tuple[str, Style]]: + def stylize_before( + self, + style: Style | str, + start: int = 0, + end: int | None = None, + ) -> Content: + """Apply a style to the text, or a portion of the text. Styles will be applied before other styles already present. + + Args: + style (Union[str, Style]): Style instance or style definition to apply. + start (int): Start offset (negative indexing is supported). Defaults to 0. + end (Optional[int], optional): End offset (negative indexing is supported), or None for end of text. Defaults to None. + """ + if not style: + return self + length = len(self) + if start < 0: + start = length + start + if end is None: + end = length + if end < 0: + end = length + end + if start >= length or end <= start: + # Span not in text or not valid + return self + return Content( + self.plain, + [Span(start, length if length < end else end, style), *self._spans], + ) + + def render( + self, + base_style: Style, + end: str = "\n", + parse_style: Callable[[str], Style] | None = None, + ) -> Iterable[tuple[str, Style]]: if not self._spans: yield self._text, base_style if end: yield end, base_style return + if parse_style is None: + + def get_style(style: str, /) -> Style: + return TRANSPARENT_STYLE if isinstance(style, str) else style + + else: + get_style = parse_style + enumerated_spans = list(enumerate(self._spans, 1)) - style_map = {index: span.style for index, span in enumerated_spans} + style_map = { + index: ( + get_style(span.style) if isinstance(span.style, str) else span.style + ) + for index, span in enumerated_spans + } style_map[0] = base_style text = self.plain @@ -625,9 +708,9 @@ def flatten_spans() -> Iterable[int]: return lines - def rstrip(self) -> Content: - """Strip whitespace from end of text.""" - text = self.plain.rstrip() + def rstrip(self, chars: str | None = None) -> Content: + """Strip characters from end of text.""" + text = self.plain.rstrip(chars) return Content(text, self._trim_spans(text, self._spans)) def rstrip_end(self, size: int) -> Content: diff --git a/src/textual/visual.py b/src/textual/visual.py new file mode 100644 index 0000000000..41cef6bff9 --- /dev/null +++ b/src/textual/visual.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import cached_property, lru_cache +from marshal import loads +from typing import Any, Iterable, Protocol, cast + +import rich.repr +from rich.console import JustifyMethod, OverflowMethod +from rich.style import Style as RichStyle + +from textual.color import TRANSPARENT, Color +from textual.strip import Strip + + +class SupportsTextualize(Protocol): + """An object that supports the textualize protocol.""" + + def textualize(self, obj: object) -> Visual | None: ... + + +def textualize(obj: object) -> Visual | None: + """Get a visual instance from an object. + + Args: + obj: An object. + + Returns: + A Visual instance to render the object, or `None` if there is no associated visual. + """ + textualize = getattr(obj, "textualize", None) + if textualize is None: + return None + visual = textualize() + if not isinstance(visual, Visual): + return None + return visual + + +@rich.repr.auto +@dataclass(frozen=True) +class Style: + """Represent a content style (color and other attributes).""" + + background: Color = TRANSPARENT + foreground: Color = TRANSPARENT + bold: bool | None = None + dim: bool | None = None + italic: bool | None = None + underline: bool | None = None + strike: bool | None = None + link: str | None = None + _meta: bytes | None = None + + def __rich_repr__(self) -> rich.repr.Result: + yield None, self.background + yield None, self.foreground + yield "bold", self.bold, None + yield "dim", self.dim, None + yield "italic", self.italic, None + yield "underline", self.underline, None + yield "strike", self.strike, None + + @lru_cache(maxsize=1024) + def __add__(self, other: object) -> Style: + if not isinstance(other, Style): + return NotImplemented + new_style = Style( + self.background + other.background, + self.foreground if other.foreground.is_transparent else other.foreground, + self.bold if other.bold is None else other.bold, + self.dim if other.dim is None else other.dim, + self.italic if other.italic is None else other.italic, + self.underline if other.underline is None else other.underline, + self.strike if other.strike is None else other.strike, + self.link if other.link is None else other.link, + self._meta if other._meta is None else other._meta, + ) + return new_style + + @cached_property + def rich_style(self) -> RichStyle: + return RichStyle( + color=(self.background + self.foreground).rich_color, + bgcolor=self.background.rich_color, + bold=self.bold, + dim=self.dim, + italic=self.italic, + underline=self.underline, + strike=self.strike, + link=self.link, + meta=self.meta, + ) + + @cached_property + def without_color(self) -> Style: + return Style( + bold=self.bold, + dim=self.dim, + italic=self.italic, + strike=self.strike, + link=self.link, + _meta=self._meta, + ) + + @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)) + + @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)) + + +class Visual(ABC): + """A Textual 'visual' object. + + Analogous to a Rich renderable, but with support for transparency. + + """ + + @abstractmethod + def render_strips( + self, + width: int, + *, + height: int | None, + base_style: Style = Style(), + justify: JustifyMethod = "left", + overflow: OverflowMethod = "fold", + no_wrap: bool = False, + tab_size: int = 8, + ) -> list[Strip]: + """Render the visual in to an iterable of strips. + + Args: + base_style: The base style. + width: Width of desired render. + height: Height of desired render. + + Returns: + An iterable of Strips. + """ + + @abstractmethod + def get_optimal_width(self, tab_size: int = 8) -> int: + """Get ideal width of the renderable to display its content. + + Args: + tab_size: Size of tabs. + + Returns: + A width in cells. + + """ + + @abstractmethod + def get_minimal_width(self, tab_size: int = 8) -> int: + """Get the minimal width (the small width that doesn't lose information). + + Args: + tab_size: Size of tabs. + + Returns: + A width in cells. + """ + + @abstractmethod + def get_height(self, width: int) -> int: + """Get the height of the visual if rendered with the given width. + + Returns: + A height in lines. + """ diff --git a/src/textual/widget.py b/src/textual/widget.py index e15dd71399..b2b1eb522d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -86,6 +86,8 @@ from textual.renderables.blank import Blank from textual.rlock import RLock from textual.strip import Strip +from textual.visual import Style as VisualStyle +from textual.visual import Visual, textualize if TYPE_CHECKING: from textual.app import App, ComposeResult @@ -1536,9 +1538,14 @@ def get_content_width(self, container: Size, viewport: Size) -> int: console = self.app.console renderable = self._render() - width = measure( - console, renderable, container.width, container_width=container.width - ) + visual = textualize(renderable) + + if visual is not None: + width = visual.get_optimal_width() + else: + width = measure( + console, renderable, container.width, container_width=container.width + ) if self.expand: width = max(container.width, width) if self.shrink: @@ -1576,7 +1583,10 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return self._content_height_cache[1] renderable = self.render() - if isinstance(renderable, Text): + visual = textualize(renderable) + if visual is not None: + height = visual.get_height(width) + elif isinstance(renderable, Text): height = ( len( Text(renderable.plain).wrap( @@ -1589,6 +1599,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int if renderable else 0 ) + else: options = self._console.options.update_width(width).update( highlight=False @@ -3521,7 +3532,7 @@ def _pseudo_classes_cache_key(self) -> tuple[int, ...]: self.is_disabled, ) - def _get_rich_justify(self) -> JustifyMethod | None: + def _get_justify_method(self) -> JustifyMethod | None: """Get the justify method that may be passed to a Rich renderable.""" text_justify: JustifyMethod | None = None if self.styles.has_rule("text_align"): @@ -3539,7 +3550,7 @@ def post_render(self, renderable: RenderableType) -> ConsoleRenderable: A new renderable. """ - text_justify = self._get_rich_justify() + text_justify = self._get_justify_method() if isinstance(renderable, str): renderable = Text.from_markup(renderable, justify=text_justify) @@ -3648,38 +3659,91 @@ def _scroll_update(self, virtual_size: Size) -> None: self.scroll_x = self.validate_scroll_x(self.scroll_x) self.scroll_y = self.validate_scroll_y(self.scroll_y) + @property + def visual_style(self) -> VisualStyle: + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) + + style = Style() + opacity = 1.0 + + for node in reversed(self.ancestors_with_self): + styles = node.styles + has_rule = styles.has_rule + opacity *= styles.opacity + if has_rule("background"): + text_background = background + styles.background.tint( + styles.background_tint + ) + background += ( + styles.background.tint(styles.background_tint) + ).multiply_alpha(opacity) + else: + text_background = background + if has_rule("color"): + color = styles.color + style += styles.text_style + if has_rule("auto_color") and styles.auto_color: + color = text_background.get_contrast_text(color.a) + + return VisualStyle( + background, + color, + bold=style.bold, + dim=style.dim, + italic=style.italic, + underline=style.underline, + strike=style.strike, + ) + def _render_content(self) -> None: """Render all lines.""" width, height = self.size renderable = self.render() - renderable = self.post_render(renderable) - options = self._console.options.update( - highlight=False, width=width, height=height - ) - segments = self._console.render(renderable, options) - lines = list( - islice( - Segment.split_and_crop_lines( - segments, width, include_new_lines=False, pad=False - ), - None, - height, + visual = textualize(renderable) + if visual is not None: + visual_style = self.visual_style + strips = visual.render_strips( + width, + height=height, + base_style=self.visual_style, + justify=self._get_justify_method() or "left", ) - ) + strips = [ + strip.adjust_cell_length(width, visual_style.rich_style) + for strip in strips + ] - styles = self.styles - align_horizontal, align_vertical = styles.content_align - lines = list( - align_lines( - lines, - _NULL_STYLE, - self.size, - align_horizontal, - align_vertical, + else: + renderable = self.post_render(renderable) + options = self._console.options.update( + highlight=False, width=width, height=height ) - ) - strips = [Strip(line, width) for line in lines] + + segments = self._console.render(renderable, options) + lines = list( + islice( + Segment.split_and_crop_lines( + segments, width, include_new_lines=False, pad=False + ), + None, + height, + ) + ) + + styles = self.styles + align_horizontal, align_vertical = styles.content_align + lines = list( + align_lines( + lines, + _NULL_STYLE, + self.size, + align_horizontal, + align_vertical, + ) + ) + strips = [Strip(line, width) for line in lines] self._render_cache = _RenderCache(self.size, strips) self._dirty_regions.clear() @@ -3886,7 +3950,7 @@ def render(self) -> RenderableType: return Blank(self.background_colors[1]) return self.css_identifier_styled - def _render(self) -> ConsoleRenderable | RichCast: + def _render(self) -> ConsoleRenderable | RichCast | Visual: """Get renderable, promoting str to text as required. Returns: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 67123d8d59..c2b24a63cf 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -254,7 +254,7 @@ def render(self) -> RenderResult: 1, 1, self.rich_style, - self._get_rich_justify() or "center", + self._get_justify_method() or "center", ) def post_render(self, renderable: RenderableType) -> ConsoleRenderable: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 0000a53f87..4adca5f8f3 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -10,6 +10,7 @@ from textual.app import RenderResult from textual.errors import RenderError +from textual.visual import SupportsTextualize from textual.widget import Widget @@ -23,9 +24,9 @@ def _check_renderable(renderable: object): Raises: RenderError: If the object can not be rendered. """ - if not is_renderable(renderable): + if not is_renderable(renderable) and not hasattr(renderable, "textualize"): raise RenderError( - f"unable to render {renderable!r}; a string, Text, or other Rich renderable is required" + f"unable to render {renderable.__class__.__name__!r} type; must be a str, Text, Rich renderable oor Textual Visual instance" ) @@ -49,11 +50,11 @@ class Static(Widget, inherit_bindings=False): } """ - _renderable: RenderableType + _renderable: RenderableType | SupportsTextualize def __init__( self, - renderable: RenderableType = "", + renderable: RenderableType | SupportsTextualize = "", *, expand: bool = False, shrink: bool = False, @@ -71,11 +72,11 @@ def __init__( _check_renderable(renderable) @property - def renderable(self) -> RenderableType: + def renderable(self) -> RenderableType | SupportsTextualize: return self._renderable or "" @renderable.setter - def renderable(self, renderable: RenderableType) -> None: + def renderable(self, renderable: RenderableType | SupportsTextualize) -> None: if isinstance(renderable, str): if self.markup: self._renderable = Text.from_markup(renderable) diff --git a/visual.py b/visual.py deleted file mode 100644 index 2060c81932..0000000000 --- a/visual.py +++ /dev/null @@ -1,53 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Iterable - -from textual.content import Style -from textual.strip import Strip - - -class Visual(ABC): - """A Textual 'visual' object. - - Analogous to a Rich renderable, but with support for transparency. - - """ - - @abstractmethod - def textualize( - self, base_style: Style, width: int, height: int | None - ) -> Iterable[Strip]: - """Render the visual in to an iterable of strips. - - Args: - base_style: The base style. - width: Width of desired render. - height: Height of desired render. - - Returns: - An iterable of Strips. - """ - - @abstractmethod - def get_optimal_width(self) -> int: - """Get ideal width of the renderable to display its content. - - Returns: - A width in cells. - - """ - - @abstractmethod - def get_minimal_width(self) -> int: - """Get the minimal width (the small width that doesn't lose information). - - Returns: - A width in cells. - """ - - @abstractmethod - def get_height(self, width: int) -> int: - """Get the height of the visual if rendered with the given width. - - Returns: - A height in lines. - """