1832 lines
60 KiB
Python
1832 lines
60 KiB
Python
|
|
"""
|
||
|
|
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("")
|