""" Content is a container for text, with spans marked up with color / style. It is equivalent to Rich's Text object, with support for more of Textual features. Unlike Rich Text, Content is *immutable* so you can't modify it in place, and most methods will return a new Content instance. This is more like the builtin str, and allows Textual to make some significant optimizations. """ from __future__ import annotations import re from functools import cached_property, total_ordering from operator import itemgetter from typing import Callable, Iterable, NamedTuple, Sequence, Union import rich.repr from rich._wrap import divide_line from rich.cells import set_cell_size from rich.console import Console from rich.segment import Segment from rich.style import Style as RichStyle from rich.terminal_theme import TerminalTheme from rich.text import Text from typing_extensions import Final, TypeAlias from textual._cells import cell_len from textual._context import active_app from textual._loop import loop_last from textual.cache import FIFOCache from textual.color import Color from textual.css.types import TextAlign, TextOverflow from textual.selection import Selection from textual.strip import Strip from textual.style import Style from textual.visual import RenderOptions, RulesMap, Visual __all__ = ["ContentType", "Content", "Span"] ContentType: TypeAlias = Union["Content", str] """Type alias used where content and a str are interchangeable in a function.""" ContentText: TypeAlias = Union["Content", Text, str] """A type that may be used to construct Text.""" ANSI_DEFAULT = Style( background=Color(0, 0, 0, 0, ansi=-1), foreground=Color(0, 0, 0, 0, ansi=-1), ) """A Style for ansi default background and foreground.""" TRANSPARENT_STYLE = Style() """A null style.""" _re_whitespace = re.compile(r"\s+$") _STRIP_CONTROL_CODES: Final = [ 7, # Bell 8, # Backspace 11, # Vertical tab 12, # Form feed 13, # Carriage return ] _CONTROL_STRIP_TRANSLATE: Final = { _codepoint: None for _codepoint in _STRIP_CONTROL_CODES } def _strip_control_codes( text: str, _translate_table: dict[int, None] = _CONTROL_STRIP_TRANSLATE ) -> str: """Remove control codes from text. Args: text (str): A string possibly contain control codes. Returns: str: String with control codes removed. """ return text.translate(_translate_table) @rich.repr.auto class Span(NamedTuple): """A style applied to a range of character offsets.""" start: int end: int style: Style | str def __rich_repr__(self) -> rich.repr.Result: yield self.start yield self.end yield "style", self.style def extend(self, cells: int) -> "Span": """Extend the span by the given number of cells. Args: cells (int): Additional space to add to end of span. Returns: Span: A span. """ if cells: start, end, style = self return Span(start, end + cells, style) return self def _shift(self, distance: int) -> "Span": """Shift a span a given distance. Note that the start offset is clamped to 0. The end offset is not clamped, as it is assumed this has already been checked by the caller. Args: distance: Number of characters to move. Returns: New Span. """ if distance < 0: start, end, style = self return Span( offset if (offset := start + distance) > 0 else 0, end + distance, style ) else: start, end, style = self return Span(start + distance, end + distance, style) @rich.repr.auto @total_ordering class Content(Visual): """Text content with marked up spans. This object can be considered immutable, although it might update its internal state in a way that is consistent with immutability. """ __slots__ = ["_text", "_spans", "_cell_length"] _NORMALIZE_TEXT_ALIGN = {"start": "left", "end": "right", "justify": "full"} def __init__( self, text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, strip_control_codes: bool = True, ) -> None: """ Initialize a Content object. Args: text: text content. spans: Optional list of spans. cell_length: Cell length of text if known, otherwise `None`. strip_control_codes: Strip control codes that may break output? """ self._text: str = ( _strip_control_codes(text) if strip_control_codes and text else text ) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length self._optimal_width_cache: int | None = None self._minimal_width_cache: int | None = None self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) self._divide_cache: ( FIFOCache[Sequence[int], list[tuple[Span, int, int]]] | None ) = None self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = ( None ) # If there are 1 or 0 spans, it can't be simplified further self._simplified = len(self._spans) <= 1 def __str__(self) -> str: return self._text @property def _is_regular(self) -> bool: """Check if the line is regular (spans.end > span.start for all spans). This is a debugging aid, and unlikely to be useful in your app. Returns: `True` if the content is regular, `False` if it is not (and broken). """ for span in self.spans: if span.end <= span.start: return False return True @cached_property def markup(self) -> str: """Get content markup to render this Text. Returns: str: A string potentially creating markup tags. """ from textual.markup import escape output: list[str] = [] plain = self.plain markup_spans = [ (0, False, None), *((span.start, False, span.style) for span in self._spans), *((span.end, True, span.style) for span in self._spans), (len(plain), True, None), ] markup_spans.sort(key=itemgetter(0, 1)) position = 0 append = output.append for offset, closing, style in markup_spans: if offset > position: append(escape(plain[position:offset])) position = offset if style: append(f"[/{style}]" if closing else f"[{style}]") markup = "".join(output) return markup @classmethod def empty(cls) -> Content: """Get an empty (blank) content""" return EMPTY_CONTENT @classmethod def from_text( cls, markup_content_or_text: ContentText, markup: bool = True ) -> Content: """Construct content from Text or str. If the argument is already Content, then return it unmodified. This method exists to make (Rich) Text and Content interchangeable. While Content is preferred, we don't want to make it harder than necessary for apps to use Text. Args: markup_content_or_text: Value to create Content from. markup: If `True`, then str values will be parsed as markup, otherwise they will be considered literals. Raises: TypeError: If the supplied argument is not a valid type. Returns: A new Content instance. """ if isinstance(markup_content_or_text, Content): return markup_content_or_text elif isinstance(markup_content_or_text, str): if markup: return cls.from_markup(markup_content_or_text) else: return cls(markup_content_or_text) elif isinstance(markup_content_or_text, Text): return cls.from_rich_text(markup_content_or_text) else: raise TypeError( "This method expects a str, a Text instance, or a Content instance" ) @classmethod def from_markup(cls, markup: str | Content, **variables: object) -> Content: """Create content from markup, optionally combined with template variables. If `markup` is already a Content instance, it will be returned unmodified. See the guide on [Content](../guide/content.md#content-class) for more details. Example: ```python content = Content.from_markup("Hello, [b]$name[/b]!", name="Will") ``` Args: markup: Content markup, or Content. **variables: Optional template variables used Returns: New Content instance. """ _rich_traceback_omit = True if isinstance(markup, Content): if variables: raise ValueError("A literal string is require to substitute variables.") return markup markup = _strip_control_codes(markup) if "[" not in markup and not variables: return Content(markup) from textual.markup import to_content content = to_content(markup, template_variables=variables or None) return content @classmethod def from_rich_text( cls, text: str | Text, console: Console | None = None ) -> Content: """Create equivalent Visual Content for str or Text. Args: text: String or Rich Text. console: A Console object to use if parsing Rich Console markup, or `None` to use app default. Returns: New Content. """ if isinstance(text, str): text = Text.from_markup(text) ansi_theme: TerminalTheme | None = None if console is not None: get_style = console.get_style else: try: app = active_app.get() except LookupError: get_style = RichStyle.parse else: get_style = app.console.get_style if text._spans: try: ansi_theme = active_app.get().ansi_theme except LookupError: ansi_theme = None spans = [ Span( start, end, ( Style.from_rich_style(get_style(style), ansi_theme) if isinstance(style, str) else Style.from_rich_style(style, ansi_theme) ), ) for start, end, style in text._spans ] else: spans = [] content = cls(text.plain, spans) if text.style: try: ansi_theme = active_app.get().ansi_theme except LookupError: ansi_theme = None content = content.stylize_before( text.style if isinstance(text.style, str) else Style.from_rich_style(text.style, ansi_theme) ) return content @classmethod def styled( cls, text: str, style: Style | str = "", cell_length: int | None = None, strip_control_codes: bool = True, ) -> Content: """Create a Content instance from text and an optional style. Args: text: String content. style: Desired style. cell_length: Cell length of text if known, otherwise `None`. strip_control_codes: Strip control codes that may break output. Returns: New Content instance. """ if not text: return EMPTY_CONTENT new_content = cls( text, [Span(0, len(text), style)] if style else None, cell_length, strip_control_codes=strip_control_codes, ) return new_content @classmethod def blank(cls, width: int, style: Style | str | None = None) -> Content: """Get a Content instance consisting of spaces. Args: width: Width of blank content (number of spaces). style: Style of blank. Returns: Content instance. """ if not width: return EMPTY_CONTENT blank = cls( " " * width, [Span(0, width, style)] if style else None, cell_length=width, ) return blank @classmethod def assemble( cls, *parts: str | Content | tuple[str, str | Style], end: str = "", strip_control_codes: bool = True, ) -> Content: """Construct new content from string, content, or tuples of (TEXT, STYLE). This is an efficient way of constructing Content composed of smaller pieces of text and / or other Content objects. Example: ```python content = Content.assemble( Content.from_markup("[b]assemble[/b]: "), # Other content "pieces of text or content into a", # Simple string of text ("a single Content instance", "underline"), # A tuple of text and a style ) ``` Args: *parts: Parts to join to gether. A *part* may be a simple string, another Content instance, or tuple containing text and a style. end: Optional end to the Content. strip_control_codes: Strip control codes that may break output. """ text: list[str] = [] spans: list[Span] = [] _Span = Span text_append = text.append position: int = 0 for part in parts: if isinstance(part, str): text_append(part) position += len(part) elif isinstance(part, tuple): part_text, part_style = part text_append(part_text) if part_style: spans.append( _Span(position, position + len(part_text), part_style), ) position += len(part_text) elif isinstance(part, Content): text_append(part.plain) if part.spans: spans.extend( [ _Span(start + position, end + position, style) for start, end, style in part.spans ] ) position += len(part.plain) if end: text_append(end) assembled_content = cls( "".join(text), spans, strip_control_codes=strip_control_codes ) return assembled_content def simplify(self) -> Content: """Simplify spans by joining contiguous spans together. This may produce faster renders if you have concatenated a large number of small pieces of content with repeating styles. Note that this modifies the Content instance in-place, which might appear to violate the immutability constraints, but it will not change the rendered output, nor its hash. Returns: Self. """ if not (spans := self._spans) or self._simplified: return self last_span = Span(-1, -1, "") new_spans: list[Span] = [] changed: bool = False for span in spans: if span.start == last_span.end and span.style == last_span.style: last_span = new_spans[-1] = Span(last_span.start, span.end, span.style) changed = True else: new_spans.append(span) last_span = span if changed: self._spans[:] = new_spans self._simplified = True return self def add_spans(self, spans: Sequence[Span]) -> Content: """Adds spans to this Content instance. Args: spans: A sequence of spans. Returns: A Content instance. """ if spans: return Content( self.plain, [*self._spans, *spans], self._cell_length, strip_control_codes=False, ) return self def __eq__(self, other: object) -> bool: """Compares text only, so that markup doesn't effect sorting.""" if isinstance(other, str): return self.plain == other elif isinstance(other, Content): return self.plain == other.plain return NotImplemented def __lt__(self, other: object) -> bool: if isinstance(other, str): return self.plain < other if isinstance(other, Content): return self.plain < other.plain return NotImplemented def is_same(self, content: Content) -> bool: """Compare to another Content object. Two Content objects are the same if their text *and* spans match. Note that if you use the `==` operator to compare Content instances, it will only consider the plain text portion of the content (and not the spans). Args: content: Content instance. Returns: `True` if this is identical to `content`, otherwise `False`. """ if self is content: return True if self.plain != content.plain: return False return self.spans == content.spans def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: """Get optimal width of the Visual to display its content. The exact definition of "optimal width" is dependant on the Visual, but will typically be wide enough to display output without cropping or wrapping, and without superfluous space. Args: rules: A mapping of style rules, such as the Widgets `styles` object. Returns: A width in cells. """ if self._optimal_width_cache is None: self._optimal_width_cache = width = max( cell_len(line) for line in self.plain.split("\n") ) else: width = self._optimal_width_cache return width + rules.get("line_pad", 0) * 2 def get_minimal_width(self, rules: RulesMap) -> int: """Minimal width is the largest single word.""" if not self.plain.strip(): return 0 if self._minimal_width_cache is None: self._minimal_width_cache = width = max( cell_len(word) for line in self.plain.splitlines() for word in line.split() if word.strip() ) else: width = self._minimal_width_cache return width + rules.get("line_pad", 0) * 2 def get_height(self, rules: RulesMap, width: int) -> int: """Get the height of the Visual if rendered at the given width. Args: rules: A mapping of style rules, such as the Widgets `styles` object. width: Width of visual in cells. Returns: A height in lines. """ get_rule = rules.get line_pad = get_rule("line_pad", 0) * 2 overflow = get_rule("text_overflow", "fold") no_wrap = get_rule("text_wrap", "wrap") == "nowrap" cache_key = (width + line_pad, overflow, no_wrap) if self._height_cache[0] == cache_key: height = self._height_cache[1] else: lines = self.without_spans._wrap_and_format( width - line_pad, overflow=overflow, no_wrap=no_wrap ) height = len(lines) self._height_cache = (cache_key, height) return height def _wrap_and_format( self, width: int, align: TextAlign = "left", overflow: TextOverflow = "fold", no_wrap: bool = False, line_pad: int = 0, tab_size: int = 8, selection: Selection | None = None, selection_style: Style | None = None, post_style: Style | None = None, get_style: Callable[[str | Style], Style] = Style.parse, ) -> list[_FormattedLine]: """Wraps the text and applies formatting. Args: width: Desired width. align: Text alignment. overflow: Overflow method. no_wrap: Disabled wrapping. tab_size: Cell with of tabs. selection: Selection information or `None` if no selection. selection_style: Selection style, or `None` if no selection. Returns: List of formatted lines. """ output_lines: list[_FormattedLine] = [] if selection is not None: get_span = selection.get_span else: def get_span(y: int) -> tuple[int, int] | None: return None for y, line in enumerate(self.split(allow_blank=True)): if post_style is not None: line = line.stylize(post_style) if selection_style is not None and (span := get_span(y)) is not None: start, end = span if end == -1: end = len(line.plain) line = line.stylize(selection_style, start, end) line = line.expand_tabs(tab_size) if no_wrap: if overflow == "fold": cuts = list(range(0, line.cell_length, width))[1:] new_lines = [ _FormattedLine(get_style, line, width, y=y, align=align) for line in line.divide(cuts) ] else: line = line.truncate(width, ellipsis=overflow == "ellipsis") content_line = _FormattedLine( get_style, line, width, y=y, align=align ) new_lines = [content_line] else: content_line = _FormattedLine(get_style, line, width, y=y, align=align) offsets = divide_line( line.plain, width - line_pad * 2, fold=overflow == "fold" ) divided_lines = content_line.content.divide(offsets) ellipsis = overflow == "ellipsis" divided_lines = [ ( line.truncate(width, ellipsis=ellipsis) if last else line.rstrip().truncate(width, ellipsis=ellipsis) ) for last, line in loop_last(divided_lines) ] new_lines = [ _FormattedLine( get_style, content.rstrip_end(width).pad(line_pad, line_pad), width, offset, y, align=align, ) for content, offset in zip(divided_lines, [0, *offsets]) ] new_lines[-1].line_end = True output_lines.extend(new_lines) return output_lines def render_strips( self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: """Render the Visual into an iterable of strips. Part of the Visual protocol. Args: width: Width of desired render. height: Height of desired render or `None` for any height. style: The base style to render on top of. options: Additional render options. Returns: An list of Strips. """ if not width: return [] get_rule = options.rules.get lines = self._wrap_and_format( width, align=get_rule("text_align", "left"), overflow=get_rule("text_overflow", "fold"), no_wrap=get_rule("text_wrap", "wrap") == "nowrap", line_pad=get_rule("line_pad", 0), tab_size=8, selection=options.selection, selection_style=options.selection_style, post_style=options.post_style, get_style=options.get_style, ) if height is not None: lines = lines[:height] strip_lines = [Strip(*line.to_strip(style)) for line in lines] return strip_lines def __len__(self) -> int: return len(self.plain) def __bool__(self) -> bool: return self._text != "" def __hash__(self) -> int: return hash(self._text) def __rich_repr__(self) -> rich.repr.Result: try: yield self._text yield "spans", self._spans, [] except AttributeError: pass @property def spans(self) -> Sequence[Span]: """A sequence of spans used to markup regions of the content. !!! warning Never attempt to mutate the spans, as this would certainly break the output--possibly in quite subtle ways! """ return self._spans @property def cell_length(self) -> int: """The cell length of the content.""" # Calculated on demand if self._cell_length is None: self._cell_length = cell_len(self.plain) return self._cell_length @property def plain(self) -> str: """Get the text as a single string.""" return self._text @property def without_spans(self) -> Content: """The content with no spans""" if self._spans: return Content(self.plain, [], self._cell_length, strip_control_codes=False) return self @property def first_line(self) -> Content: """The first line of the content.""" if "\n" not in self.plain: return self return self[: self.plain.index("\n")] def __getitem__(self, slice: int | slice) -> Content: def get_text_at(offset: int) -> "Content": _Span = Span content = Content( self.plain[offset], spans=[ _Span(0, 1, style) for start, end, style in self._spans if end > offset >= start ], strip_control_codes=False, ) return content if isinstance(slice, int): return get_text_at(slice) else: start, stop, step = slice.indices(len(self.plain)) if step == 1: if start == 0: if stop >= len(self.plain): return self text = self.plain[:stop] sliced_content = Content( text, self._trim_spans(text, self._spans), strip_control_codes=False, ) else: text = self.plain[start:stop] spans = [ span._shift(-start) for span in self._spans if span.end - start > 0 ] sliced_content = Content( text, self._trim_spans(text, spans), strip_control_codes=False ) return sliced_content else: # This would be a bit of work to implement efficiently # For now, its not required raise TypeError("slices with step!=1 are not supported") def __add__(self, other: Content | str) -> Content: if isinstance(other, str): return Content(self._text + other, self._spans, strip_control_codes=False) if isinstance(other, Content): offset = len(self.plain) content = Content( self.plain + other.plain, ( self._spans + [ Span(start + offset, end + offset, style) for start, end, style in other._spans ] ), ( None if self._cell_length is not None else (self.cell_length + other.cell_length) ), ) return content return NotImplemented def __radd__(self, other: str) -> Content: if not isinstance(other, str): return NotImplemented return Content(other) + self @classmethod def _trim_spans(cls, text: str, spans: list[Span]) -> list[Span]: """Remove or modify any spans that are over the end of the text.""" max_offset = len(text) _Span = Span spans = [ ( span if span.end < max_offset else _Span(span.start, min(max_offset, span.end), span.style) ) for span in spans if span.start < max_offset ] 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( f"{self.plain}{content}", self._spans, ( None if self._cell_length is None else self._cell_length + cell_len(content) ), strip_control_codes=False, ) return EMPTY_CONTENT.join([self, content]) def append_text(self, text: str, style: Style | str = "") -> Content: """Append text give as a string, with an optional style. Args: text: Text to append. style: Optional style for new text. Returns: New content. """ return self.append(Content.styled(text, style)) def join(self, lines: Iterable[Content | str]) -> Content: """Join an iterable of content or strings. This works much like the join method on `str` objects. Self is the separator (which maybe empty) placed between each string or Content. Args: lines: An iterable of other Content instances or or strings. 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 if isinstance(line, Content) else Content(line, strip_control_codes=False) ) if not last: yield self else: for line in lines: yield ( line if isinstance(line, Content) else Content(line, strip_control_codes=False) ) extend_text = text.extend extend_spans = spans.extend offset = 0 _Span = Span total_cell_length: int | None = self._cell_length for content in iter_content(): if not content: continue extend_text(content._text) extend_spans( _Span(offset + start, offset + end, style) for start, end, style in content._spans if style ) offset += len(content._text) 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 wrap( self, width: int, *, align: TextAlign = "left", overflow: TextOverflow = "fold" ) -> list[Content]: """Wrap text so that it fits within the given dimensions. Note that Textual will automatically wrap Content in widgets. This method is only required if you need some additional processing to lines. Args: width: Maximum width of the line (in cells). align: Alignment of lines. overflow: Overflow of lines (what happens when the text doesn't fit). Returns: A list of Content objects, one per line. """ lines = self._wrap_and_format(width, align, overflow) content_lines = [line.content for line in lines] return content_lines def fold(self, width: int) -> list[Content]: """Fold this line into a list of lines which have a cell length no less than 2 and no greater than `width`. Folded lines may be 1 less than the width if it contains double width characters (which may not be subdivided). Note that this method will not do any word wrapping. For that, see [wrap()][textual.content.Content.wrap]. Args: width: Desired maximum width (in cells) Returns: List of content instances. """ if not self: return [self] text = self.plain lines: list[Content] = [] position = 0 width = max(width, 2) while True: snip = text[position : position + width] if not snip: break snip_cell_length = cell_len(snip) if snip_cell_length < width: # last snip lines.append(self[position : position + width]) break if snip_cell_length == width: # Cell length is exactly width lines.append(self[position : position + width]) position += len(snip) continue # TODO: Can this be more efficient? extra_cells = snip_cell_length - width if start_snip := extra_cells // 2: snip_cell_length -= cell_len(snip[-start_snip:]) snip = snip[: len(snip) - start_snip] while snip_cell_length > width: snip_cell_length -= cell_len(snip[-1]) snip = snip[:-1] lines.append(self[position : position + len(snip)]) position += len(snip) return lines def get_style_at_offset(self, offset: int) -> Style: """Get the style of a character at give offset. Args: offset (int): Offset into text (negative indexing supported) Returns: Style: A Style instance. """ # TODO: This is a little inefficient, it is only used by full justify if offset < 0: offset = len(self) + offset style = Style() for start, end, span_style in self._spans: if end > offset >= start: style += span_style return style def truncate( self, max_width: int, *, ellipsis=False, pad: bool = False, ) -> Content: """Truncate the content at a given cell width. Args: max_width: The maximum width in cells. ellipsis: Insert an ellipsis when cropped. pad: Pad the content if less than `max_width`. Returns: New Content. """ length = self.cell_length if length == max_width: return self text = self.plain spans = self._spans if pad and length < max_width: spaces = max_width - length text = f"{self.plain}{' ' * spaces}" return Content(text, spans, max_width, strip_control_codes=False) elif length > max_width: if ellipsis and max_width: text = set_cell_size(self.plain, max_width - 1) + "…" else: text = set_cell_size(self.plain, max_width) spans = self._trim_spans(text, self._spans) return Content(text, spans, max_width, strip_control_codes=False) else: return self def pad_left(self, count: int, character: str = " ") -> Content: """Pad the left with a given character. Args: count (int): Number of characters to pad. character (str, optional): Character to pad with. Defaults to " ". """ assert len(character) == 1, "Character must be a string of length 1" if count: text = f"{character * count}{self.plain}" _Span = Span spans = [ _Span(start + count, end + count, style) for start, end, style in self._spans ] content = Content( text, spans, None if self._cell_length is None else self._cell_length + count, strip_control_codes=False, ) return content return self def extend_right(self, count: int, character: str = " ") -> Content: """Add repeating characters (typically spaces) to the content with the style(s) of the last character. Args: count: Number of spaces. character: Character to add with. Returns: A Content instance. """ if count: plain = self.plain plain_len = len(plain) return Content( f"{plain}{character * count}", [ (span.extend(count) if span.end == plain_len else span) for span in self._spans ], None if self._cell_length is None else self._cell_length + count, strip_control_codes=False, ) return self def pad_right(self, count: int, character: str = " ") -> Content: """Pad the right with a given character. Args: count (int): Number of characters to pad. character (str, optional): Character to pad with. Defaults to " ". """ assert len(character) == 1, "Character must be a string of length 1" if count: return Content( f"{self.plain}{character * count}", self._spans, None if self._cell_length is None else self._cell_length + count, strip_control_codes=False, ) return self def pad(self, left: int, right: int, character: str = " ") -> Content: """Pad both the left and right edges with a given number of characters. Args: left (int): Number of characters to pad on the left. right (int): Number of characters to pad on the right. character (str, optional): Character to pad with. Defaults to " ". """ assert len(character) == 1, "Character must be a string of length 1" if left or right: text = f"{character * left}{self.plain}{character * right}" _Span = Span if left: spans = [ _Span(start + left, end + left, style) for start, end, style in self._spans ] else: spans = self._spans content = Content( text, spans, None if self._cell_length is None else self._cell_length + left + right, strip_control_codes=False, ) return content return self def center(self, width: int, ellipsis: bool = False) -> Content: """Align a line to the center. Args: width: Desired width of output. ellipsis: Insert ellipsis if content is truncated. Returns: New line Content. """ content = self.rstrip().truncate(width, ellipsis=ellipsis) left = (width - content.cell_length) // 2 right = width - left content = content.pad(left, right) return content def right(self, width: int, ellipsis: bool = False) -> Content: """Align a line to the right. Args: width: Desired width of output. ellipsis: Insert ellipsis if content is truncated. Returns: New line Content. """ content = self.rstrip().truncate(width, ellipsis=ellipsis) content = content.pad_left(width - content.cell_length) return content def right_crop(self, amount: int = 1) -> Content: """Remove a number of characters from the end of the text. Args: amount: Number of characters to crop. Returns: New Content """ max_offset = len(self.plain) - amount _Span = Span spans = [ ( span if span.end < max_offset else _Span(span.start, min(max_offset, span.end), span.style) ) for span in self._spans if span.start < max_offset ] text = self.plain[:-amount] length = None if self._cell_length is None else self._cell_length - amount return Content(text, spans, length, strip_control_codes=False) 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: 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, self._spans + [Span(start, length if length < end else end, style)], self._cell_length, strip_control_codes=False, ) 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 applies with this method 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], self._cell_length, strip_control_codes=False, ) def render( self, base_style: Style = Style.null(), end: str = "\n", parse_style: Callable[[str | Style], Style] | None = None, ) -> Iterable[tuple[str, Style]]: """Render Content in to an iterable of strings and styles. This is typically called by Textual when displaying Content, but may be used if you want to do more advanced processing of the output. Args: base_style: The style used as a base. This will typically be the style of the widget underneath the content. end: Text to end the output, such as a new line. parse_style: Method to parse a style. Use `App.parse_style` to apply CSS variables in styles. Returns: An iterable of string and styles, which make up the content. """ if not self._spans: yield (self._text, base_style) if end: yield end, base_style return get_style: Callable[[str | Style], Style] if parse_style is None: def _get_style(style: str | Style) -> Style: """The default get_style method.""" if isinstance(style, Style): return style try: visual_style = Style.parse(style) except Exception: visual_style = Style.null() return visual_style get_style = _get_style else: get_style = parse_style enumerated_spans = list(enumerate(self._spans, 1)) 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 spans = [ (0, False, 0), *((span.start, False, index) for index, span in enumerated_spans), *((span.end, True, index) for index, span in enumerated_spans), (len(text), True, 0), ] spans.sort(key=itemgetter(0, 1)) stack: list[int] = [] stack_append = stack.append stack_pop = stack.remove style_cache: dict[tuple[int, ...], Style] = {} style_cache_get = style_cache.get combine = Style.combine def get_current_style() -> Style: """Construct current style from stack.""" cache_key = tuple(stack) cached_style = style_cache_get(cache_key) if cached_style is not None: return cached_style styles = [style_map[_style_id] for _style_id in cache_key] current_style = combine(styles) style_cache[cache_key] = current_style return current_style for (offset, leaving, style_id), (next_offset, _, _) in zip(spans, spans[1:]): if leaving: stack_pop(style_id) else: stack_append(style_id) if next_offset > offset: yield text[offset:next_offset], get_current_style() if end: yield end, base_style def render_segments( self, base_style: Style = Style.null(), end: str = "" ) -> list[Segment]: """Render the Content in to a list of segments. Args: base_style: Base style for render (style under the content). Defaults to Style.null(). end: Character to end the segments with. Defaults to "". Returns: A list of segments. """ _Segment = Segment segments = [ _Segment(text, (style.rich_style if style else None)) for text, style in self.render(base_style, end) ] return segments def __rich__(self): """Allow Content to be rendered with rich.print.""" from rich.segment import Segments return Segments(self.render_segments(Style(), "\n")) def _divide_spans(self, offsets: tuple[int, ...]) -> list[tuple[Span, int, int]]: """Divide content from a list of offset to cut. Args: offsets: A tuple of indices in to the text. Returns: A list of tuples containing Spans and their line offsets. """ if self._divide_cache is None: self._divide_cache = FIFOCache(4) if (cached_result := self._divide_cache.get(offsets)) is not None: return cached_result line_ranges = list(zip(offsets, offsets[1:])) text_length = len(self.plain) line_count = len(line_ranges) span_ranges: list[tuple[Span, int, int]] = [] for span in self._spans: span_start, span_end, _style = span if span_start >= text_length: continue span_end = min(text_length, span_end) lower_bound = 0 upper_bound = line_count start_line_no = (lower_bound + upper_bound) // 2 while True: line_start, line_end = line_ranges[start_line_no] if span_start < line_start: upper_bound = start_line_no - 1 elif span_start > line_end: lower_bound = start_line_no + 1 else: break start_line_no = (lower_bound + upper_bound) // 2 if span_end < line_end: end_line_no = start_line_no else: end_line_no = lower_bound = start_line_no upper_bound = line_count while True: line_start, line_end = line_ranges[end_line_no] if span_end < line_start: upper_bound = end_line_no - 1 elif span_end > line_end: lower_bound = end_line_no + 1 else: break end_line_no = (lower_bound + upper_bound) // 2 span_ranges.append((span, start_line_no, end_line_no + 1)) self._divide_cache[offsets] = span_ranges return span_ranges def divide(self, offsets: Sequence[int]) -> list[Content]: """Divide the content at the given offsets. This will cut the content in to pieces, and return those pieces. Note that the number of pieces return will be one greater than the number of cuts. Args: offsets: Sequence of offsets (in characters) of where to apply the cuts. Returns: List of Content instances which combined would be equal to the whole. """ if not offsets: return [self] offsets = sorted(offsets) text = self.plain divide_offsets = tuple([0, *offsets, len(text)]) line_ranges = list(zip(divide_offsets, divide_offsets[1:])) line_text = [text[start:end] for start, end in line_ranges] new_lines = [Content(line, None) for line in line_text] if not self._spans: return new_lines _line_appends = [line._spans.append for line in new_lines] _Span = Span for ( (span_start, span_end, style), start_line, end_line, ) in self._divide_spans(divide_offsets): for line_no in range(start_line, end_line): line_start, line_end = line_ranges[line_no] new_start = max(0, span_start - line_start) new_end = min(span_end - line_start, line_end - line_start) if new_end > new_start: _line_appends[line_no](_Span(new_start, new_end, style)) return new_lines def split( self, separator: str = "\n", *, include_separator: bool = False, allow_blank: bool = False, ) -> list[Content]: """Split rich text into lines, preserving styles. Args: separator (str, optional): String to split on. Defaults to "\\\\n". include_separator (bool, optional): Include the separator in the lines. Defaults to False. allow_blank (bool, optional): Return a blank line if the text ends with a separator. Defaults to False. Returns: List[Content]: A list of Content, one per line of the original. """ assert separator, "separator must not be empty" text = self.plain if separator not in text: return [self] cache_key = (separator, include_separator, allow_blank) if self._split_cache is None: self._split_cache = FIFOCache(4) if (cached_result := self._split_cache.get(cache_key)) is not None: return cached_result.copy() if include_separator: lines = self.divide( [match.end() for match in re.finditer(re.escape(separator), text)], ) else: def flatten_spans() -> Iterable[int]: for match in re.finditer(re.escape(separator), text): yield from match.span() lines = [ line for line in self.divide(list(flatten_spans())) if line.plain != separator ] if not allow_blank and text.endswith(separator): lines.pop() self._split_cache[cache_key] = lines return lines 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: """Remove whitespace beyond a certain width at the end of the text. Args: size (int): The desired size of the text. """ text_length = len(self) if text_length > size: excess = text_length - size whitespace_match = _re_whitespace.search(self.plain) if whitespace_match is not None: whitespace_count = len(whitespace_match.group(0)) return self.right_crop(min(whitespace_count, excess)) return self def extend_style(self, spaces: int) -> Content: """Extend the Text given number of spaces where the spaces have the same style as the last character. Args: spaces (int): Number of spaces to add to the Text. Returns: New content with additional spaces at the end. """ if spaces <= 0: return self spans = self._spans new_spaces = " " * spaces if spans: end_offset = len(self) spans = [ span.extend(spaces) if span.end >= end_offset else span for span in spans ] return Content(self._text + new_spaces, spans, self.cell_length + spaces) return Content(self._text + new_spaces, self._spans, self._cell_length) def expand_tabs(self, tab_size: int = 8) -> Content: """Converts tabs to spaces. Args: tab_size (int, optional): Size of tabs. Defaults to 8. """ if "\t" not in self.plain: return self if not self._spans: return Content(self.plain.expandtabs(tab_size)) new_text: list[Content] = [] append = new_text.append for line in self.split("\n", include_separator=True): if "\t" not in line.plain: append(line) else: cell_position = 0 parts = line.split("\t", include_separator=True) for part in parts: if part.plain.endswith("\t"): part = Content( part._text[:-1] + " ", part._spans, part._cell_length ) cell_position += part.cell_length tab_remainder = cell_position % tab_size if tab_remainder: spaces = tab_size - tab_remainder part = part.extend_style(spaces) cell_position += spaces else: cell_position += part.cell_length append(part) content = EMPTY_CONTENT.join(new_text) return content def highlight_regex( self, highlight_regex: re.Pattern[str] | str, *, style: Style | str, maximum_highlights: int | None = None, ) -> Content: """Apply a style to text that matches a regular expression. Args: highlight_regex: Regular expression as a string, or compiled. style: Style to apply. maximum_highlights: Maximum number of matches to highlight, or `None` for no maximum. Returns: new content. """ spans: list[Span] = self._spans.copy() append_span = spans.append _Span = Span plain = self.plain if isinstance(highlight_regex, str): re_highlight = re.compile(highlight_regex) else: re_highlight = highlight_regex count = 0 for match in re_highlight.finditer(plain): start, end = match.span() if end > start: append_span(_Span(start, end, style)) if ( maximum_highlights is not None and (count := count + 1) >= maximum_highlights ): break return Content(self._text, spans, cell_length=self._cell_length) class _FormattedLine: """A line of content with additional formatting information. This class is used internally within Content, and you are unlikely to need it an an app. """ def __init__( self, get_style: Callable[[str | Style], Style], content: Content, width: int, x: int = 0, y: int = 0, align: TextAlign = "left", line_end: bool = False, link_style: Style | None = None, ) -> None: self.get_style = get_style self.content = content self.width = width self.x = x self.y = y self.align = align self.line_end = line_end self.link_style = link_style @property def plain(self) -> str: return self.content.plain def to_strip(self, style: Style) -> tuple[list[Segment], int]: _Segment = Segment align = self.align width = self.width pad_left = pad_right = 0 content = self.content x = self.x y = self.y get_style = self.get_style if align in ("start", "left") or (align == "justify" and self.line_end): pass elif align == "center": excess_space = width - self.content.cell_length pad_left = excess_space // 2 pad_right = excess_space - pad_left elif align in ("end", "right"): pad_left = width - self.content.cell_length elif align == "justify": words = content.split(" ", include_separator=False) words_size = sum(cell_len(word.plain.rstrip(" ")) for word in words) num_spaces = len(words) - 1 spaces = [1] * num_spaces index = 0 if spaces: while words_size + num_spaces < width: spaces[len(spaces) - index - 1] += 1 num_spaces += 1 index = (index + 1) % len(spaces) segments: list[Segment] = [] add_segment = segments.append x = self.x for index, word in enumerate(words): for text, text_style in word.render( style, end="", parse_style=get_style ): add_segment( _Segment( text, (style + text_style).rich_style_with_offset(x, y) ) ) x += len(text) + 1 if index < len(spaces) and (pad := spaces[index]): add_segment(_Segment(" " * pad, (style + text_style).rich_style)) return segments, width segments = ( [Segment(" " * pad_left, style.background_style.rich_style)] if pad_left else [] ) add_segment = segments.append for text, text_style in content.render(style, end="", parse_style=get_style): add_segment( _Segment(text, (style + text_style).rich_style_with_offset(x, y)) ) x += len(text) if pad_right: segments.append( _Segment(" " * pad_right, style.background_style.rich_style) ) return (segments, content.cell_length + pad_left + pad_right) def _apply_link_style( self, link_style: RichStyle, segments: list[Segment] ) -> list[Segment]: _Segment = Segment segments = [ _Segment( text, ( style if style._meta is None else (style + link_style if "@click" in style.meta else style) ), control, ) for text, style, control in segments if style is not None ] return segments EMPTY_CONTENT: Final = Content("")