817 lines
26 KiB
Python
817 lines
26 KiB
Python
|
|
"""
|
||
|
|
This module contains the `Strip` class and related objects.
|
||
|
|
|
||
|
|
A `Strip` contains the result of rendering a widget.
|
||
|
|
See [Line API](/guide/widgets#line-api) for how to use Strips.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from functools import lru_cache
|
||
|
|
from typing import Any, Iterable, Iterator, Sequence
|
||
|
|
|
||
|
|
import rich.repr
|
||
|
|
from rich.cells import cell_len, set_cell_size
|
||
|
|
from rich.color import ColorSystem
|
||
|
|
from rich.console import Console, ConsoleOptions, RenderResult
|
||
|
|
from rich.measure import Measurement
|
||
|
|
from rich.segment import Segment
|
||
|
|
from rich.style import Style, StyleType
|
||
|
|
|
||
|
|
from textual._segment_tools import index_to_cell_position, line_pad
|
||
|
|
from textual.cache import FIFOCache
|
||
|
|
from textual.color import Color
|
||
|
|
from textual.css.types import AlignHorizontal, AlignVertical
|
||
|
|
from textual.filter import LineFilter
|
||
|
|
|
||
|
|
SGR_STYLES = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53"]
|
||
|
|
|
||
|
|
|
||
|
|
def get_line_length(segments: Iterable[Segment]) -> int:
|
||
|
|
"""Get the line length (total length of all segments).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
segments: Iterable of segments.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Length of line in cells.
|
||
|
|
"""
|
||
|
|
_cell_len = cell_len
|
||
|
|
return sum([_cell_len(text) for text, _, control in segments if not control])
|
||
|
|
|
||
|
|
|
||
|
|
class StripRenderable:
|
||
|
|
"""A renderable which renders a list of strips into lines."""
|
||
|
|
|
||
|
|
def __init__(self, strips: list[Strip], width: int | None = None) -> None:
|
||
|
|
self._strips = strips
|
||
|
|
self._width = width
|
||
|
|
|
||
|
|
def __rich_console__(
|
||
|
|
self, console: Console, options: ConsoleOptions
|
||
|
|
) -> RenderResult:
|
||
|
|
new_line = Segment.line()
|
||
|
|
for strip in self._strips:
|
||
|
|
yield from strip
|
||
|
|
yield new_line
|
||
|
|
|
||
|
|
def __rich_measure__(
|
||
|
|
self, console: "Console", options: "ConsoleOptions"
|
||
|
|
) -> Measurement:
|
||
|
|
if self._width is None:
|
||
|
|
width = max(strip.cell_length for strip in self._strips)
|
||
|
|
else:
|
||
|
|
width = self._width
|
||
|
|
return Measurement(width, width)
|
||
|
|
|
||
|
|
|
||
|
|
@rich.repr.auto
|
||
|
|
class Strip:
|
||
|
|
"""Represents a 'strip' (horizontal line) of a Textual Widget.
|
||
|
|
|
||
|
|
A Strip is like an immutable list of Segments. The immutability allows for effective caching.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
segments: An iterable of segments.
|
||
|
|
cell_length: The cell length if known, or None to calculate on demand.
|
||
|
|
"""
|
||
|
|
|
||
|
|
__slots__ = [
|
||
|
|
"_segments",
|
||
|
|
"_cell_length",
|
||
|
|
"_divide_cache",
|
||
|
|
"_crop_cache",
|
||
|
|
"_style_cache",
|
||
|
|
"_filter_cache",
|
||
|
|
"_render_cache",
|
||
|
|
"_line_length_cache",
|
||
|
|
"_crop_extend_cache",
|
||
|
|
"_offsets_cache",
|
||
|
|
"_link_ids",
|
||
|
|
"_cell_count",
|
||
|
|
]
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self, segments: Iterable[Segment], cell_length: int | None = None
|
||
|
|
) -> None:
|
||
|
|
self._segments = list(segments)
|
||
|
|
self._cell_length = cell_length
|
||
|
|
self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
|
||
|
|
self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(16)
|
||
|
|
self._style_cache: FIFOCache[Style, Strip] = FIFOCache(16)
|
||
|
|
self._filter_cache: FIFOCache[tuple[LineFilter, Color], Strip] = FIFOCache(4)
|
||
|
|
self._line_length_cache: FIFOCache[
|
||
|
|
tuple[int, Style | None],
|
||
|
|
Strip,
|
||
|
|
] = FIFOCache(4)
|
||
|
|
self._crop_extend_cache: FIFOCache[
|
||
|
|
tuple[int, int, Style | None],
|
||
|
|
Strip,
|
||
|
|
] = FIFOCache(4)
|
||
|
|
self._offsets_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4)
|
||
|
|
self._render_cache: str | None = None
|
||
|
|
self._link_ids: set[str] | None = None
|
||
|
|
self._cell_count: int | None = None
|
||
|
|
|
||
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
||
|
|
try:
|
||
|
|
yield self._segments
|
||
|
|
yield self.cell_length
|
||
|
|
except AttributeError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
@property
|
||
|
|
def text(self) -> str:
|
||
|
|
"""Segment text."""
|
||
|
|
return "".join(segment.text for segment in self._segments)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def link_ids(self) -> set[str]:
|
||
|
|
"""A set of the link ids in this Strip."""
|
||
|
|
if self._link_ids is None:
|
||
|
|
self._link_ids = {
|
||
|
|
style._link_id for _, style, _ in self._segments if style is not None
|
||
|
|
}
|
||
|
|
return self._link_ids
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
|
||
|
|
"""Create a blank strip.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
cell_length: Desired cell length.
|
||
|
|
style: Style of blank.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New strip.
|
||
|
|
"""
|
||
|
|
segment_style = Style.parse(style) if isinstance(style, str) else style
|
||
|
|
return cls([Segment(" " * cell_length, segment_style)], cell_length)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_lines(
|
||
|
|
cls, lines: list[list[Segment]], cell_length: int | None = None
|
||
|
|
) -> list[Strip]:
|
||
|
|
"""Convert lines (lists of segments) to a list of Strips.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
lines: List of lines, where a line is a list of segments.
|
||
|
|
cell_length: Cell length of lines (must be same) or None if not known.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
List of strips.
|
||
|
|
"""
|
||
|
|
return [cls(segments, cell_length) for segments in lines]
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def align(
|
||
|
|
cls,
|
||
|
|
strips: list[Strip],
|
||
|
|
style: Style,
|
||
|
|
width: int,
|
||
|
|
height: int | None,
|
||
|
|
horizontal: AlignHorizontal,
|
||
|
|
vertical: AlignVertical,
|
||
|
|
) -> Iterable[Strip]:
|
||
|
|
"""Align a list of strips on both axis.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
strips: A list of strips, such as from a render.
|
||
|
|
style: The Rich style of additional space.
|
||
|
|
width: Width of container.
|
||
|
|
height: Height of container.
|
||
|
|
horizontal: Horizontal alignment method.
|
||
|
|
vertical: Vertical alignment method.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
An iterable of strips, with additional padding.
|
||
|
|
|
||
|
|
"""
|
||
|
|
if not strips:
|
||
|
|
return
|
||
|
|
line_lengths = [strip.cell_length for strip in strips]
|
||
|
|
shape_width = max(line_lengths)
|
||
|
|
shape_height = len(line_lengths)
|
||
|
|
|
||
|
|
def blank_lines(count: int) -> Iterable[Strip]:
|
||
|
|
"""Create blank lines.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
count: Desired number of blank lines.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
An iterable of blank lines.
|
||
|
|
"""
|
||
|
|
blank = cls([Segment(" " * width, style)], width)
|
||
|
|
for _ in range(count):
|
||
|
|
yield blank
|
||
|
|
|
||
|
|
top_blank_lines = bottom_blank_lines = 0
|
||
|
|
if height is not None:
|
||
|
|
vertical_excess_space = max(0, height - shape_height)
|
||
|
|
|
||
|
|
if vertical == "top":
|
||
|
|
bottom_blank_lines = vertical_excess_space
|
||
|
|
elif vertical == "middle":
|
||
|
|
top_blank_lines = vertical_excess_space // 2
|
||
|
|
bottom_blank_lines = vertical_excess_space - top_blank_lines
|
||
|
|
elif vertical == "bottom":
|
||
|
|
top_blank_lines = vertical_excess_space
|
||
|
|
|
||
|
|
if top_blank_lines:
|
||
|
|
yield from blank_lines(top_blank_lines)
|
||
|
|
|
||
|
|
if horizontal == "left":
|
||
|
|
for strip in strips:
|
||
|
|
if strip.cell_length == width:
|
||
|
|
yield strip
|
||
|
|
else:
|
||
|
|
yield Strip(
|
||
|
|
line_pad(strip._segments, 0, width - strip.cell_length, style),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
elif horizontal == "center":
|
||
|
|
left_space = max(0, width - shape_width) // 2
|
||
|
|
for strip in strips:
|
||
|
|
if strip.cell_length == width:
|
||
|
|
yield strip
|
||
|
|
else:
|
||
|
|
yield Strip(
|
||
|
|
line_pad(
|
||
|
|
strip._segments,
|
||
|
|
left_space,
|
||
|
|
width - strip.cell_length - left_space,
|
||
|
|
style,
|
||
|
|
),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
|
||
|
|
elif horizontal == "right":
|
||
|
|
for strip in strips:
|
||
|
|
if strip.cell_length == width:
|
||
|
|
yield strip
|
||
|
|
else:
|
||
|
|
yield cls(
|
||
|
|
line_pad(strip._segments, width - strip.cell_length, 0, style),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
|
||
|
|
if bottom_blank_lines:
|
||
|
|
yield from blank_lines(bottom_blank_lines)
|
||
|
|
|
||
|
|
def index_to_cell_position(self, index: int) -> int:
|
||
|
|
"""Given a character index, return the cell position of that character.
|
||
|
|
This is the sum of the cell lengths of all the characters *before* the character
|
||
|
|
at `index`.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
index: The index to convert.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The cell position of the character at `index`.
|
||
|
|
"""
|
||
|
|
return index_to_cell_position(self._segments, index)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def cell_length(self) -> int:
|
||
|
|
"""Get the number of cells required to render this object."""
|
||
|
|
# Done on demand and cached, as this is an O(n) operation
|
||
|
|
if self._cell_length is None:
|
||
|
|
self._cell_length = get_line_length(self._segments)
|
||
|
|
return self._cell_length
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def join(cls, strips: Iterable[Strip | None]) -> Strip:
|
||
|
|
"""Join a number of strips into one.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
strips: An iterable of Strips.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new combined strip.
|
||
|
|
"""
|
||
|
|
join_strips = [
|
||
|
|
strip for strip in strips if strip is not None and strip.cell_count
|
||
|
|
]
|
||
|
|
segments = [segment for strip in join_strips for segment in strip._segments]
|
||
|
|
cell_length: int | None = None
|
||
|
|
if any([strip._cell_length is None for strip in join_strips]):
|
||
|
|
cell_length = None
|
||
|
|
else:
|
||
|
|
cell_length = sum([strip._cell_length or 0 for strip in join_strips])
|
||
|
|
joined_strip = cls(segments, cell_length)
|
||
|
|
if all(strip._render_cache is not None for strip in join_strips):
|
||
|
|
joined_strip._render_cache = "".join(
|
||
|
|
[strip._render_cache for strip in join_strips]
|
||
|
|
)
|
||
|
|
return joined_strip
|
||
|
|
|
||
|
|
def __add__(self, other: Strip) -> Strip:
|
||
|
|
return Strip.join([self, other])
|
||
|
|
|
||
|
|
def __bool__(self) -> bool:
|
||
|
|
return not not self._segments # faster than bool(...)
|
||
|
|
|
||
|
|
def __iter__(self) -> Iterator[Segment]:
|
||
|
|
return iter(self._segments)
|
||
|
|
|
||
|
|
def __reversed__(self) -> Iterator[Segment]:
|
||
|
|
return reversed(self._segments)
|
||
|
|
|
||
|
|
def __len__(self) -> int:
|
||
|
|
return len(self._segments)
|
||
|
|
|
||
|
|
def __eq__(self, strip: object) -> bool:
|
||
|
|
return isinstance(strip, Strip) and (self._segments == strip._segments)
|
||
|
|
|
||
|
|
def __getitem__(self, index: int | slice) -> Strip:
|
||
|
|
if isinstance(index, int):
|
||
|
|
index = slice(index, index + 1)
|
||
|
|
return self.crop(
|
||
|
|
index.start, self.cell_count if index.stop is None else index.stop
|
||
|
|
)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def cell_count(self) -> int:
|
||
|
|
"""Number of cells in the strip"""
|
||
|
|
if self._cell_count is None:
|
||
|
|
self._cell_count = sum(len(segment.text) for segment in self._segments)
|
||
|
|
return self._cell_count
|
||
|
|
|
||
|
|
def extend_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
|
||
|
|
"""Extend the cell length if it is less than the given value.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
cell_length: Required minimum cell length.
|
||
|
|
style: Style for padding if the cell length is extended.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new Strip.
|
||
|
|
"""
|
||
|
|
if self.cell_length < cell_length:
|
||
|
|
missing_space = cell_length - self.cell_length
|
||
|
|
segments = self._segments + [Segment(" " * missing_space, style)]
|
||
|
|
return Strip(segments, cell_length)
|
||
|
|
else:
|
||
|
|
return self
|
||
|
|
|
||
|
|
def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
|
||
|
|
"""Adjust the cell length, possibly truncating or extending.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
cell_length: New desired cell length.
|
||
|
|
style: Style when extending, or `None`.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new strip with the supplied cell length.
|
||
|
|
"""
|
||
|
|
|
||
|
|
if self.cell_length == cell_length:
|
||
|
|
return self
|
||
|
|
|
||
|
|
cache_key = (cell_length, style)
|
||
|
|
cached_strip = self._line_length_cache.get(cache_key)
|
||
|
|
if cached_strip is not None:
|
||
|
|
return cached_strip
|
||
|
|
|
||
|
|
new_line: list[Segment]
|
||
|
|
line = self._segments
|
||
|
|
current_cell_length = self.cell_length
|
||
|
|
|
||
|
|
_Segment = Segment
|
||
|
|
|
||
|
|
if current_cell_length < cell_length:
|
||
|
|
# Cell length is larger, so pad with spaces.
|
||
|
|
new_line = line + [
|
||
|
|
_Segment(" " * (cell_length - current_cell_length), style)
|
||
|
|
]
|
||
|
|
strip = Strip(new_line, cell_length)
|
||
|
|
|
||
|
|
elif current_cell_length > cell_length:
|
||
|
|
# Cell length is shorter so we need to truncate.
|
||
|
|
new_line = []
|
||
|
|
append = new_line.append
|
||
|
|
line_length = 0
|
||
|
|
for segment in line:
|
||
|
|
segment_length = segment.cell_length
|
||
|
|
if line_length + segment_length < cell_length:
|
||
|
|
append(segment)
|
||
|
|
line_length += segment_length
|
||
|
|
else:
|
||
|
|
text, segment_style, _ = segment
|
||
|
|
text = set_cell_size(text, cell_length - line_length)
|
||
|
|
append(_Segment(text, segment_style))
|
||
|
|
break
|
||
|
|
strip = Strip(new_line, cell_length)
|
||
|
|
else:
|
||
|
|
# Strip is already the required cell length, so return self.
|
||
|
|
strip = self
|
||
|
|
|
||
|
|
self._line_length_cache[cache_key] = strip
|
||
|
|
return strip
|
||
|
|
|
||
|
|
def simplify(self) -> Strip:
|
||
|
|
"""Simplify the segments (join segments with same style).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New strip.
|
||
|
|
"""
|
||
|
|
line = Strip(
|
||
|
|
Segment.simplify(self._segments),
|
||
|
|
self._cell_length,
|
||
|
|
)
|
||
|
|
return line
|
||
|
|
|
||
|
|
def discard_meta(self) -> Strip:
|
||
|
|
"""Remove all meta from segments.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New strip.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def remove_meta_from_segment(segment: Segment) -> Segment:
|
||
|
|
"""Build a Segment with no meta.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
segment: Segment.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Segment, sans meta.
|
||
|
|
"""
|
||
|
|
text, style, control = segment
|
||
|
|
if style is None:
|
||
|
|
return segment
|
||
|
|
style = style.copy()
|
||
|
|
style._meta = None
|
||
|
|
return Segment(text, style, control)
|
||
|
|
|
||
|
|
return Strip(
|
||
|
|
[remove_meta_from_segment(segment) for segment in self._segments],
|
||
|
|
self._cell_length,
|
||
|
|
)
|
||
|
|
|
||
|
|
def apply_filter(self, filter: LineFilter, background: Color) -> Strip:
|
||
|
|
"""Apply a filter to all segments in the strip.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
filter: A line filter object.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new Strip.
|
||
|
|
"""
|
||
|
|
cached_strip = self._filter_cache.get((filter, background))
|
||
|
|
if cached_strip is None:
|
||
|
|
cached_strip = Strip(
|
||
|
|
filter.apply(self._segments, background), self._cell_length
|
||
|
|
)
|
||
|
|
self._filter_cache[(filter, background)] = cached_strip
|
||
|
|
return cached_strip
|
||
|
|
|
||
|
|
def style_links(self, link_id: str, link_style: Style) -> Strip:
|
||
|
|
"""Apply a style to Segments with the given link_id.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
link_id: A link id.
|
||
|
|
link_style: Style to apply.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New strip (or same Strip if no changes).
|
||
|
|
"""
|
||
|
|
|
||
|
|
_Segment = Segment
|
||
|
|
if link_id not in self.link_ids:
|
||
|
|
return self
|
||
|
|
segments = [
|
||
|
|
_Segment(
|
||
|
|
text,
|
||
|
|
(
|
||
|
|
(style + link_style if style is not None else None)
|
||
|
|
if (style and not style._null and style._link_id == link_id)
|
||
|
|
else style
|
||
|
|
),
|
||
|
|
control,
|
||
|
|
)
|
||
|
|
for text, style, control in self._segments
|
||
|
|
]
|
||
|
|
return Strip(segments, self._cell_length)
|
||
|
|
|
||
|
|
def crop_extend(self, start: int, end: int, style: Style | None) -> Strip:
|
||
|
|
"""Crop between two points, extending the length if required.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
start: Start offset of crop.
|
||
|
|
end: End offset of crop.
|
||
|
|
style: Style of additional padding.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New cropped Strip.
|
||
|
|
"""
|
||
|
|
cache_key = (start, end, style)
|
||
|
|
cached_result = self._crop_extend_cache.get(cache_key)
|
||
|
|
if cached_result is not None:
|
||
|
|
return cached_result
|
||
|
|
strip = self.extend_cell_length(end, style).crop(start, end)
|
||
|
|
self._crop_extend_cache[cache_key] = strip
|
||
|
|
return strip
|
||
|
|
|
||
|
|
def crop(self, start: int, end: int | None = None) -> Strip:
|
||
|
|
"""Crop a strip between two cell positions.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
start: The start cell position (inclusive).
|
||
|
|
end: The end cell position (exclusive).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new Strip.
|
||
|
|
"""
|
||
|
|
|
||
|
|
start = max(0, start)
|
||
|
|
end = self.cell_length if end is None else min(self.cell_length, end)
|
||
|
|
if start == 0 and end == self.cell_length:
|
||
|
|
return self
|
||
|
|
if end <= start:
|
||
|
|
return Strip([], 0)
|
||
|
|
cache_key = (start, end)
|
||
|
|
cached = self._crop_cache.get(cache_key)
|
||
|
|
if cached is not None:
|
||
|
|
return cached
|
||
|
|
_cell_len = cell_len
|
||
|
|
pos = 0
|
||
|
|
output_segments: list[Segment] = []
|
||
|
|
add_segment = output_segments.append
|
||
|
|
iter_segments = iter(self._segments)
|
||
|
|
segment: Segment | None = None
|
||
|
|
if start >= self.cell_length:
|
||
|
|
strip = Strip([], 0)
|
||
|
|
else:
|
||
|
|
for segment in iter_segments:
|
||
|
|
end_pos = pos + _cell_len(segment.text)
|
||
|
|
if end_pos > start:
|
||
|
|
segment = segment.split_cells(start - pos)[1]
|
||
|
|
break
|
||
|
|
pos = end_pos
|
||
|
|
|
||
|
|
if end >= self.cell_length:
|
||
|
|
# The end crop is the end of the segments, so we can collect all remaining segments
|
||
|
|
if segment:
|
||
|
|
add_segment(segment)
|
||
|
|
output_segments.extend(iter_segments)
|
||
|
|
strip = Strip(output_segments, self.cell_length - start)
|
||
|
|
else:
|
||
|
|
pos = start
|
||
|
|
while segment is not None:
|
||
|
|
end_pos = pos + _cell_len(segment.text)
|
||
|
|
if end_pos < end:
|
||
|
|
add_segment(segment)
|
||
|
|
else:
|
||
|
|
add_segment(segment.split_cells(end - pos)[0])
|
||
|
|
break
|
||
|
|
pos = end_pos
|
||
|
|
segment = next(iter_segments, None)
|
||
|
|
strip = Strip(output_segments, end - start)
|
||
|
|
self._crop_cache[cache_key] = strip
|
||
|
|
return strip
|
||
|
|
|
||
|
|
def divide(self, cuts: Iterable[int]) -> Sequence[Strip]:
|
||
|
|
"""Divide the strip into multiple smaller strips by cutting at given (cell) indices.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
cuts: An iterable of cell positions as ints.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new list of strips.
|
||
|
|
"""
|
||
|
|
|
||
|
|
pos = 0
|
||
|
|
cell_length = self.cell_length
|
||
|
|
cuts = [cut for cut in cuts if cut <= cell_length]
|
||
|
|
cache_key = tuple(cuts)
|
||
|
|
if (cached := self._divide_cache.get(cache_key)) is not None:
|
||
|
|
return cached
|
||
|
|
|
||
|
|
strips: list[Strip]
|
||
|
|
if cuts == [cell_length]:
|
||
|
|
strips = [self]
|
||
|
|
else:
|
||
|
|
strips = []
|
||
|
|
add_strip = strips.append
|
||
|
|
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||
|
|
add_strip(Strip(segments, cut - pos))
|
||
|
|
pos = cut
|
||
|
|
|
||
|
|
self._divide_cache[cache_key] = strips
|
||
|
|
return strips
|
||
|
|
|
||
|
|
def apply_style(self, style: Style) -> Strip:
|
||
|
|
"""Apply a style to the Strip.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
style: A Rich style.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new strip.
|
||
|
|
"""
|
||
|
|
cached = self._style_cache.get(style)
|
||
|
|
if cached is not None:
|
||
|
|
return cached
|
||
|
|
styled_strip = Strip(
|
||
|
|
Segment.apply_style(self._segments, style), self.cell_length
|
||
|
|
)
|
||
|
|
self._style_cache[style] = styled_strip
|
||
|
|
return styled_strip
|
||
|
|
|
||
|
|
def apply_meta(self, meta: dict[str, Any]) -> Strip:
|
||
|
|
"""Apply meta to all segments.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
meta: A dict of meta information.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A new strip.
|
||
|
|
|
||
|
|
"""
|
||
|
|
meta_style = Style.from_meta(meta)
|
||
|
|
return self.apply_style(meta_style)
|
||
|
|
|
||
|
|
def _apply_link_style(self, link_style: Style) -> Strip:
|
||
|
|
segments = self._segments
|
||
|
|
_Segment = Segment
|
||
|
|
segments = [
|
||
|
|
(
|
||
|
|
_Segment(
|
||
|
|
text,
|
||
|
|
(
|
||
|
|
style
|
||
|
|
if style._meta is None
|
||
|
|
else (style + link_style if "@click" in style.meta else style)
|
||
|
|
),
|
||
|
|
control,
|
||
|
|
)
|
||
|
|
if style
|
||
|
|
else _Segment(text)
|
||
|
|
)
|
||
|
|
for text, style, control in segments
|
||
|
|
]
|
||
|
|
return Strip(segments, self._cell_length)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
@lru_cache(maxsize=16384)
|
||
|
|
def render_ansi(cls, style: Style, color_system: ColorSystem) -> str:
|
||
|
|
"""Render ANSI codes for a give style.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
style: A Rich style.
|
||
|
|
color_system: Color system enumeration.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A string of ANSI escape sequences to render the style.
|
||
|
|
"""
|
||
|
|
sgr: list[str]
|
||
|
|
if attributes := style._attributes & style._set_attributes:
|
||
|
|
_style_map = SGR_STYLES
|
||
|
|
sgr = [
|
||
|
|
_style_map[bit_offset]
|
||
|
|
for bit_offset in range(attributes.bit_length())
|
||
|
|
if attributes & (1 << bit_offset)
|
||
|
|
]
|
||
|
|
else:
|
||
|
|
sgr = []
|
||
|
|
if (color := style._color) is not None:
|
||
|
|
sgr.extend(color.downgrade(color_system).get_ansi_codes())
|
||
|
|
if (bgcolor := style._bgcolor) is not None:
|
||
|
|
sgr.extend(bgcolor.downgrade(color_system).get_ansi_codes(False))
|
||
|
|
ansi = style._ansi = ";".join(sgr)
|
||
|
|
return ansi
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def render_style(cls, style: Style, text: str, color_system: ColorSystem) -> str:
|
||
|
|
"""Render a Rich style and text.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
style: Style to render.
|
||
|
|
text: Content string.
|
||
|
|
color_system: Color system enumeration.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Text with ANSI escape sequences.
|
||
|
|
"""
|
||
|
|
if (ansi := style._ansi) is None:
|
||
|
|
ansi = cls.render_ansi(style, color_system)
|
||
|
|
output = f"\x1b[{ansi}m{text}\x1b[0m" if ansi else text
|
||
|
|
if style._link:
|
||
|
|
output = (
|
||
|
|
f"\x1b]8;id={style._link_id};{style._link}\x1b\\{output}\x1b]8;;\x1b\\"
|
||
|
|
)
|
||
|
|
return output
|
||
|
|
|
||
|
|
def render(self, console: Console) -> str:
|
||
|
|
"""Render the strip into terminal sequences.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
console: Console instance.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Rendered sequences.
|
||
|
|
"""
|
||
|
|
if self._render_cache is None:
|
||
|
|
color_system = console._color_system or ColorSystem.TRUECOLOR
|
||
|
|
render = self.render_style
|
||
|
|
self._render_cache = "".join(
|
||
|
|
[
|
||
|
|
(
|
||
|
|
text
|
||
|
|
if style is None
|
||
|
|
else render(style, text, color_system=color_system)
|
||
|
|
)
|
||
|
|
for text, style, _ in self._segments
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
return self._render_cache
|
||
|
|
|
||
|
|
def crop_pad(self, cell_length: int, left: int, right: int, style: Style) -> Strip:
|
||
|
|
"""Crop the strip to `cell_length`, and add optional padding.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
cell_length: Cell length of strip prior to padding.
|
||
|
|
left: Additional padding on the left.
|
||
|
|
right: Additional padding on the right.
|
||
|
|
style: Style of any padding.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Cropped and padded strip.
|
||
|
|
"""
|
||
|
|
if cell_length != self.cell_length:
|
||
|
|
strip = self.adjust_cell_length(cell_length, style)
|
||
|
|
else:
|
||
|
|
strip = self
|
||
|
|
if not (left or right):
|
||
|
|
return strip
|
||
|
|
segments = strip._segments.copy()
|
||
|
|
if left:
|
||
|
|
segments.insert(0, Segment(" " * left, style))
|
||
|
|
if right:
|
||
|
|
segments.append(Segment(" " * right, style))
|
||
|
|
return Strip(segments, cell_length + left + right)
|
||
|
|
|
||
|
|
def text_align(self, width: int, align: AlignHorizontal) -> Strip:
|
||
|
|
if align == "left":
|
||
|
|
if self.cell_length == width:
|
||
|
|
return self
|
||
|
|
else:
|
||
|
|
return Strip(
|
||
|
|
line_pad(self._segments, 0, width - self.cell_length, Style.null()),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
elif align == "center":
|
||
|
|
left_space = max(0, width - self.cell_length) // 2
|
||
|
|
|
||
|
|
if self.cell_length == width:
|
||
|
|
return self
|
||
|
|
else:
|
||
|
|
return Strip(
|
||
|
|
line_pad(
|
||
|
|
self._segments,
|
||
|
|
left_space,
|
||
|
|
width - self.cell_length - left_space,
|
||
|
|
Style.null(),
|
||
|
|
),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
|
||
|
|
elif align == "right":
|
||
|
|
if self.cell_length == width:
|
||
|
|
return self
|
||
|
|
else:
|
||
|
|
return Strip(
|
||
|
|
line_pad(self._segments, width - self.cell_length, 0, Style.null()),
|
||
|
|
width,
|
||
|
|
)
|
||
|
|
|
||
|
|
def apply_offsets(self, x: int, y: int) -> Strip:
|
||
|
|
"""Apply offsets used in text selection.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
x: Offset on X axis (column).
|
||
|
|
y: Offset on Y axis (row).
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
New strip.
|
||
|
|
"""
|
||
|
|
cache_key = (x, y)
|
||
|
|
if (cached_strip := self._offsets_cache.get(cache_key)) is not None:
|
||
|
|
return cached_strip
|
||
|
|
segments = self._segments
|
||
|
|
strip_segments: list[Segment] = []
|
||
|
|
for segment in segments:
|
||
|
|
text, style, _ = segment
|
||
|
|
offset_style = Style.from_meta({"offset": (x, y)})
|
||
|
|
strip_segments.append(
|
||
|
|
Segment(text, style + offset_style if style else offset_style)
|
||
|
|
)
|
||
|
|
x += len(segment.text)
|
||
|
|
strip = Strip(strip_segments, self._cell_length)
|
||
|
|
strip._render_cache = self._render_cache
|
||
|
|
self._offsets_cache[cache_key] = strip
|
||
|
|
return strip
|