adds Strip primitive

This commit is contained in:
Will McGugan
2022-12-26 18:06:35 +00:00
parent 87329b6d07
commit 6f82ad9c4a
11 changed files with 2294 additions and 2127 deletions

View File

@@ -26,10 +26,12 @@ from rich.style import Style
from . import errors
from ._cells import cell_len
from ._loop import loop_last
from ._types import Lines
from .strip import Strip
from ._types import Strips
from ._typing import TypeAlias
from .geometry import NULL_OFFSET, Offset, Region, Size
if TYPE_CHECKING:
from .widget import Widget
@@ -66,8 +68,8 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
class LayoutUpdate:
"""A renderable containing the result of a render for a given region."""
def __init__(self, lines: Lines, region: Region) -> None:
self.lines = lines
def __init__(self, strips: Strips, region: Region) -> None:
self.strips = strips
self.region = region
def __rich_console__(
@@ -76,7 +78,7 @@ class LayoutUpdate:
x = self.region.x
new_line = Segment.line()
move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
yield move_to(x, y)
yield from line
if not last:
@@ -92,7 +94,7 @@ class ChopsUpdate:
def __init__(
self,
chops: list[dict[int, list[Segment] | None]],
chops: list[dict[int, Strip | None]],
spans: list[tuple[int, int, int]],
chop_ends: list[list[int]],
) -> None:
@@ -121,9 +123,9 @@ class ChopsUpdate:
for y, x1, x2 in self.spans:
line = chops[y]
ends = chop_ends[y]
for end, (x, segments) in zip(ends, line.items()):
for end, (x, strip) in zip(ends, line.items()):
# TODO: crop to x extents
if segments is None:
if strip is None:
continue
if x > x2 or end <= x1:
@@ -131,10 +133,10 @@ class ChopsUpdate:
if x2 > x >= x1 and end <= x2:
yield move_to(x, y)
yield from segments
yield from strip
continue
iter_segments = iter(segments)
iter_segments = iter(strip)
if x < x1:
for segment in iter_segments:
next_x = x + _cell_len(segment.text)
@@ -635,7 +637,7 @@ class Compositor:
def _get_renders(
self, crop: Region | None = None
) -> Iterable[tuple[Region, Region, Lines]]:
) -> Iterable[tuple[Region, Region, Strips]]:
"""Get rendered widgets (lists of segments) in the composition.
Returns:
@@ -685,19 +687,21 @@ class Compositor:
_Region(delta_x, delta_y, new_width, new_height)
)
@classmethod
def _assemble_chops(
cls, chops: list[dict[int, list[Segment] | None]]
) -> list[list[Segment]]:
"""Combine chops in to lines."""
from_iterable = chain.from_iterable
segment_lines: list[list[Segment]] = [
list(from_iterable(line for line in bucket.values() if line is not None))
for bucket in chops
]
return segment_lines
# @classmethod
# def _assemble_chops(cls, chops: list[dict[int, Strip | None]]) -> list[Strip]:
# """Combine chops in to lines."""
def render(self, full: bool = False) -> RenderableType | None:
# [Strip.join(strips) for strips in chops]
# from_iterable = chain.from_iterable
# segment_lines: list[list[Segment]] = [
# list(from_iterable(strip for strip in bucket.values() if strip is not None))
# for bucket in chops
# ]
# return segment_lines
def render(self, full: bool = False) -> RenderableType:
"""Render a layout.
Returns:
@@ -728,8 +732,6 @@ class Compositor:
else:
return None
divide = Segment.divide
# Maps each cut on to a list of segments
cuts = self.cuts
@@ -738,19 +740,19 @@ class Compositor:
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
)
# A mapping of cut index to a list of segments for each line
chops: list[dict[int, list[Segment] | None]]
chops: list[dict[int, Strip | None]]
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
cut_segments: Iterable[list[Segment]]
cut_strips: Iterable[Strip]
# Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(crop)
intersection = Region.intersection
for region, clip, lines in renders:
for region, clip, strips in renders:
render_region = intersection(region, clip)
for y, line in zip(render_region.line_range, lines):
for y, strip in zip(render_region.line_range, strips):
if not is_rendered_line(y):
continue
@@ -763,20 +765,20 @@ class Compositor:
]
if len(final_cuts) <= 2:
# Two cuts, which means the entire line
cut_segments = [line]
cut_strips = [strip]
else:
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
cut_segments = divide(line, relative_cuts)
cut_strips = list(strip.divide(relative_cuts))
# Since we are painting front to back, the first segments for a cut "wins"
for cut, segments in zip(final_cuts, cut_segments):
for cut, segments in zip(final_cuts, cut_strips):
if chops_line[cut] is None:
chops_line[cut] = segments
if full:
render_lines = self._assemble_chops(chops)
return LayoutUpdate(render_lines, screen_region)
render_strips = [Strip.join(chop.values()) for chop in chops]
return LayoutUpdate(render_strips, screen_region)
else:
chop_ends = [cut_set[1:] for cut_set in cuts]
return ChopsUpdate(chops, spans, chop_ends)

View File

@@ -10,7 +10,7 @@ from rich.segment import Segment
from rich.style import Style
from ._cells import cell_len
from ._types import Lines
from ._types import Strips
from .css.types import AlignHorizontal, AlignVertical
from .geometry import Size
@@ -22,8 +22,8 @@ def line_crop(
Args:
segments (list[Segment]): A list of Segments for a line.
start (int): Start offset
end (int): End offset (exclusive)
start (int): Start offset (cells)
end (int): End offset (cells, exclusive)
total (int): Total cell length of segments.
Returns:
list[Segment]: A new shorter list of segments
@@ -130,7 +130,7 @@ def line_pad(
def align_lines(
lines: Lines,
lines: Strips,
style: Style,
size: Size,
horizontal: AlignHorizontal,
@@ -153,7 +153,7 @@ def align_lines(
width, height = size
shape_width, shape_height = Segment.get_shape(lines)
def blank_lines(count: int) -> Lines:
def blank_lines(count: int) -> Strips:
return [[Segment(" " * width, style)]] * count
top_blank_lines = bottom_blank_lines = 0

View File

@@ -11,12 +11,13 @@ from ._border import get_box, render_row
from ._filter import LineFilter
from ._opacity import _apply_opacity
from ._segment_tools import line_crop, line_pad, line_trim
from ._types import Lines
from ._types import Strips
from ._typing import TypeAlias
from .color import Color
from .geometry import Region, Size, Spacing
from .renderables.text_opacity import TextOpacity
from .renderables.tint import Tint
from .strip import Strip
if TYPE_CHECKING:
from .css.styles import StylesBase
@@ -25,35 +26,6 @@ if TYPE_CHECKING:
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]
def style_links(
segments: Iterable[Segment], link_id: str, link_style: Style
) -> list[Segment]:
"""Apply a style to the given link id.
Args:
segments (Iterable[Segment]): Segments.
link_id (str): A link id.
link_style (Style): Style to apply.
Returns:
list[Segment]: A list of new segments.
"""
_Segment = Segment
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 segments
]
return segments
@lru_cache(1024 * 8)
def make_blank(width, style: Style) -> Segment:
"""Make a blank segment.
@@ -95,7 +67,7 @@ class StylesCache:
"""
def __init__(self) -> None:
self._cache: dict[int, list[Segment]] = {}
self._cache: dict[int, Strip] = {}
self._dirty_lines: set[int] = set()
self._width = 1
@@ -123,7 +95,7 @@ class StylesCache:
self._cache.clear()
self._dirty_lines.clear()
def render_widget(self, widget: Widget, crop: Region) -> Lines:
def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
"""Render the content for a widget.
Args:
@@ -135,7 +107,7 @@ class StylesCache:
"""
base_background, background = widget.background_colors
styles = widget.styles
lines = self.render(
strips = self.render(
styles,
widget.region.size,
base_background,
@@ -147,7 +119,6 @@ class StylesCache:
filter=widget.app._filter,
)
if widget.auto_links:
_style_links = style_links
hover_style = widget.hover_style
link_hover_style = widget.link_hover_style
if (
@@ -157,12 +128,12 @@ class StylesCache:
and "@click" in hover_style.meta
):
if link_hover_style:
lines = [
_style_links(line, hover_style.link_id, link_hover_style)
for line in lines
strips = [
strip.style_links(hover_style.link_id, link_hover_style)
for strip in strips
]
return lines
return strips
def render(
self,
@@ -175,7 +146,7 @@ class StylesCache:
padding: Spacing | None = None,
crop: Region | None = None,
filter: LineFilter | None = None,
) -> Lines:
) -> list[Strip]:
"""Render a widget content plus CSS styles.
Args:
@@ -202,15 +173,14 @@ class StylesCache:
if width != self._width:
self.clear()
self._width = width
lines: Lines = []
add_line = lines.append
simplify = Segment.simplify
strips: list[Strip] = []
add_strip = strips.append
is_dirty = self._dirty_lines.__contains__
render_line = self.render_line
for y in crop.line_range:
if is_dirty(y) or y not in self._cache:
line = render_line(
strip = render_line(
styles,
y,
size,
@@ -220,21 +190,19 @@ class StylesCache:
background,
render_content_line,
)
line = list(simplify(line))
self._cache[y] = line
self._cache[y] = strip
else:
line = self._cache[y]
strip = self._cache[y]
if filter:
line = filter.filter(line)
add_line(line)
strip = strip.apply_filter(filter)
add_strip(strip)
self._dirty_lines.difference_update(crop.line_range)
if crop.column_span != (0, width):
_line_crop = line_crop
x1, x2 = crop.column_span
lines = [_line_crop(line, x1, x2, width) for line in lines]
strips = [strip.crop(x1, x2) for strip in strips]
return lines
return strips
def render_line(
self,
@@ -246,7 +214,7 @@ class StylesCache:
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
) -> list[Segment]:
) -> Strip:
"""Render a styled line.
Args:
@@ -402,4 +370,5 @@ class StylesCache:
else:
line = [*line, right]
return post(line)
strip = Strip(post(line), width)
return strip

View File

@@ -2,10 +2,12 @@ from typing import Awaitable, Callable, List, TYPE_CHECKING, Union
from rich.segment import Segment
from textual._typing import Protocol
from ._typing import Protocol
if TYPE_CHECKING:
from .message import Message
from .strip import Strip
class MessageTarget(Protocol):
@@ -27,5 +29,5 @@ class EventTarget(Protocol):
...
Lines = List[List[Segment]]
Strips = List["Strip"]
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]

View File

@@ -30,6 +30,16 @@ class ScrollView(Widget):
"""Not transparent, i.e. renders something."""
return False
def watch_scroll_x(self, new_value: float) -> None:
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.position = int(new_value)
self.refresh()
def watch_scroll_y(self, new_value: float) -> None:
if self.show_vertical_scrollbar:
self.vertical_scrollbar.position = int(new_value)
self.refresh()
def on_mount(self):
self._refresh_scrollbars()
@@ -68,6 +78,8 @@ class ScrollView(Widget):
virtual_size (Size): New virtual size.
container_size (Size): New container size.
"""
if self._size != size or container_size != container_size:
self.refresh()
if (
self._size != size
or virtual_size != self.virtual_size
@@ -77,9 +89,7 @@ class ScrollView(Widget):
virtual_size = self.virtual_size
self._container_size = size - self.styles.gutter.totals
self._scroll_update(virtual_size)
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
self.refresh()
def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented).

181
src/textual/strip.py Normal file
View File

@@ -0,0 +1,181 @@
from __future__ import annotations
from itertools import chain
from typing import Iterator, Iterable, Sequence
import rich.repr
from rich.cells import cell_len, set_cell_size
from rich.segment import Segment
from rich.style import Style
from ._filter import LineFilter
from ._segment_tools import line_crop
from ._profile import timer
@rich.repr.auto
class Strip:
__slots__ = ["_segments", "_cell_length", "_divide_cache"]
def __init__(
self, segments: Iterable[Segment], cell_length: int | None = None
) -> None:
self._segments = list(segments)
self._cell_length = cell_length
self._divide_cache: dict[tuple[int], list[Strip]] = {}
def __rich_repr__(self) -> rich.repr.Result:
yield self._segments
yield self.cell_length
@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 = Segment.get_line_length(self._segments)
return self._cell_length
@classmethod
def join(cls, strips: Iterable[Strip | None]) -> Strip:
segments: list[list[Segment]] = []
add_segments = segments.append
total_cell_length = 0
for strip in strips:
if strip is None:
continue
total_cell_length += strip.cell_length
add_segments(strip._segments)
strip = cls(chain.from_iterable(segments), total_cell_length)
return strip
def __bool__(self) -> bool:
return bool(self._segments)
def __iter__(self) -> Iterator[Segment]:
return iter(self._segments)
def __len__(self) -> int:
return len(self._segments)
def __eq__(self, strip: Strip) -> bool:
return (
self._segments == strip._segments and self.cell_length == strip.cell_length
)
def adjust_line_length(self, cell_length: int, style: Style | None) -> Strip:
new_line: list[Segment]
line = self._segments
current_cell_length = self.cell_length
_Segment = Segment
if current_cell_length < cell_length:
new_line = line + [
_Segment(" " * (cell_length - current_cell_length), style)
]
elif current_cell_length > cell_length:
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
else:
return self
return Strip(new_line, cell_length)
def simplify(self) -> Strip:
line = Strip(
Segment.simplify(self._segments),
self._cell_length,
)
return line
def apply_filter(self, filter: LineFilter) -> Strip:
return Strip(filter.filter(self._segments), self._cell_length)
def style_links(self, link_id: str, link_style: Style) -> Strip:
_Segment = Segment
if not any(
segment.style._link_id == link_id
for segment in self._segments
if segment.style
):
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(self, start: int, end: int) -> Strip:
_cell_len = cell_len
pos = 0
output_segments: list[Segment] = []
add_segment = output_segments.append
iter_segments = iter(self._segments)
segment: Segment | None = None
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
else:
return Strip([], 0)
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)
return Strip(output_segments, self.cell_length - start)
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)
return Strip(output_segments, end - start)
def divide(self, cuts: Iterable[int]) -> list[Strip]:
pos = 0
cache_key = tuple(cuts)
cached = self._divide_cache.get(cache_key)
if cached is not None:
return cached
strips: list[Strip] = []
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

View File

@@ -43,7 +43,7 @@ from ._easing import DEFAULT_SCROLL_EASING
from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from ._types import Lines
from ._types import Strips
from .actions import SkipAction
from .await_remove import AwaitRemove
from .binding import Binding
@@ -57,6 +57,7 @@ from .message import Message
from .messages import CallbackType
from .reactive import Reactive
from .render import measure
from .strip import Strip
from .walk import walk_depth_first
if TYPE_CHECKING:
@@ -156,7 +157,7 @@ class RenderCache(NamedTuple):
"""Stores results of a previous render."""
size: Size
lines: Lines
lines: Strips
class WidgetError(Exception):
@@ -2118,7 +2119,7 @@ class Widget(DOMNode):
line = [Segment(" " * self.size.width, self.rich_style)]
return line
def render_lines(self, crop: Region) -> Lines:
def render_lines(self, crop: Region) -> list[Strip]:
"""Render the widget in to lines.
Args:
@@ -2127,8 +2128,8 @@ class Widget(DOMNode):
Returns:
Lines: A list of list of segments.
"""
lines = self._styles_cache.render_widget(self, crop)
return lines
strips = self._styles_cache.render_widget(self, crop)
return strips
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Rich style in a widget at a given relative offset.

View File

@@ -14,7 +14,7 @@ from rich.text import Text, TextType
from .. import events, messages
from .._cache import LRUCache
from .._segment_tools import line_crop
from .._types import Lines
from .._types import Strips
from ..geometry import Region, Size, Spacing, clamp
from ..reactive import Reactive
from ..render import measure
@@ -207,10 +207,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.row_count = 0
self._y_offsets: list[tuple[int, int]] = []
self._row_render_cache: LRUCache[
tuple[int, int, Style, int, int], tuple[Lines, Lines]
tuple[int, int, Style, int, int], tuple[Strips, Strips]
]
self._row_render_cache = LRUCache(1000)
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines]
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Strips]
self._cell_render_cache = LRUCache(10000)
self._line_cache: LRUCache[
tuple[int, int, int, int, int, int, Style], list[Segment]
@@ -450,7 +450,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
width: int,
cursor: bool = False,
hover: bool = False,
) -> Lines:
) -> Strips:
"""Render the given cell.
Args:
@@ -488,7 +488,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
base_style: Style,
cursor_column: int = -1,
hover_column: int = -1,
) -> tuple[Lines, Lines]:
) -> tuple[Strips, Strips]:
"""Render a row in to lines for each cell.
Args:

View File

@@ -15,7 +15,7 @@ from ..geometry import Size, Region
from ..scroll_view import ScrollView
from .._cache import LRUCache
from .._segment_tools import line_crop
from .._types import Lines
from .._types import Strips
class TextLog(ScrollView, can_focus=True):
@@ -143,7 +143,7 @@ class TextLog(ScrollView, can_focus=True):
line = list(Segment.apply_style(line, self.rich_style))
return line
def render_lines(self, crop: Region) -> Lines:
def render_lines(self, crop: Region) -> Strips:
"""Render the widget in to lines.
Args:

File diff suppressed because one or more lines are too long

View File

@@ -4,13 +4,14 @@ from rich.segment import Segment
from rich.style import Style
from textual._styles_cache import StylesCache
from textual._types import Lines
from textual._types import Strips
from textual.color import Color
from textual.css.styles import Styles
from textual.geometry import Region, Size
from textual.strip import Strip
def _extract_content(lines: Lines):
def _extract_content(lines: Strips):
"""Extract the text content from lines."""
content = ["".join(segment.text for segment in line) for line in lines]
return content
@@ -44,10 +45,11 @@ def test_no_styles():
)
style = Style.from_color(bgcolor=Color.parse("green").rich_color)
expected = [
[Segment("foo", style)],
[Segment("bar", style)],
[Segment("baz", style)],
Strip([Segment("foo", style)], 3),
Strip([Segment("bar", style)], 3),
Strip([Segment("baz", style)], 3),
]
assert lines == expected