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

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