diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index b55a8fcbc..1e7ab1603 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from . import errors from ._cells import cell_len from ._loop import loop_last from .strip import Strip -from ._types import Strips from ._typing import TypeAlias from .geometry import NULL_OFFSET, Offset, Region, Size @@ -67,7 +66,7 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" class LayoutUpdate: """A renderable containing the result of a render for a given region.""" - def __init__(self, strips: Strips, region: Region) -> None: + def __init__(self, strips: list[Strip], region: Region) -> None: self.strips = strips self.region = region @@ -634,7 +633,7 @@ class Compositor: def _get_renders( self, crop: Region | None = None - ) -> Iterable[tuple[Region, Region, Strips]]: + ) -> Iterable[tuple[Region, Region, list[Strip]]]: """Get rendered widgets (lists of segments) in the composition. Returns: diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index f08ae5ef9..c12b06cbc 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -10,7 +10,6 @@ from rich.segment import Segment from rich.style import Style from ._cells import cell_len -from ._types import Strips from .css.types import AlignHorizontal, AlignVertical from .geometry import Size diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index a0e20ef85..737fa1527 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -11,7 +11,6 @@ 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 Strips from ._typing import TypeAlias from .color import Color from .geometry import Region, Size, Spacing diff --git a/src/textual/_types.py b/src/textual/_types.py index 4aa68d2b5..52b2390cf 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -29,6 +29,5 @@ class EventTarget(Protocol): ... -Strips = List["Strip"] SegmentLines = List[List["Segment"]] CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/strip.py b/src/textual/strip.py index 3a805b01e..871602bce 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -42,6 +42,32 @@ class Strip: yield self._segments yield self.cell_length + @classmethod + def blank(cls, cell_length: int, style: Style | None) -> Strip: + """Create a blank strip. + + Args: + cell_length (int): Desired cell length. + style (Style | None): Style of blank. + + Returns: + Strip: New strip. + """ + return cls([Segment(" " * cell_length, style)], cell_length) + + @classmethod + def from_lines(cls, lines: list[list[Segment]], cell_length: int) -> list[Strip]: + """Convert lines (lists of segments) to a list of Strips. + + Args: + lines (list[list[Segment]]): List of lines, where a line is a list of segments. + cell_length (int): Cell length of lines (must be same). + + Returns: + list[Strip]: List of strips. + """ + return [cls(segments, cell_length) for segments in lines] + @property def cell_length(self) -> int: """Get the number of cells required to render this object.""" @@ -194,6 +220,8 @@ class Strip: Returns: Strip: A new Strip. """ + if start == 0 and end == self.cell_length: + return self cache_key = (start, end) cached = self._crop_cache.get(cache_key) if cached is not None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 8d2da1810..389c3b996 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -155,7 +155,7 @@ class RenderCache(NamedTuple): """Stores results of a previous render.""" size: Size - lines: list[list[Segment]] + lines: list[Strip] class WidgetError(Exception): @@ -2097,11 +2097,11 @@ class Widget(DOMNode): align_vertical, ) ) - - self._render_cache = RenderCache(self.size, lines) + strips = [Strip(line, width) for line in lines] + self._render_cache = RenderCache(self.size, strips) self._dirty_regions.clear() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: """Render a line of content. Args: @@ -2115,7 +2115,7 @@ class Widget(DOMNode): try: line = self._render_cache.lines[y] except IndexError: - line = [Segment(" " * self.size.width, self.rich_style)] + line = Strip.blank(self.size.width, self.rich_style) return line def render_lines(self, crop: Region) -> list[Strip]: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 732193a70..dd993fb36 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -19,6 +19,7 @@ from ..geometry import Region, Size, Spacing, clamp from ..reactive import Reactive from ..render import measure from ..scroll_view import ScrollView +from ..strip import Strip from .._typing import Literal CursorType = Literal["cell", "row", "column"] @@ -214,9 +215,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): tuple[int, int, Style, bool, bool], SegmentLines ] self._cell_render_cache = LRUCache(10000) - self._line_cache: LRUCache[ - tuple[int, int, int, int, int, int, Style], list[Segment] - ] + self._line_cache: LRUCache[tuple[int, int, int, int, int, int, Style], Strip] self._line_cache = LRUCache(1000) self._line_no = 0 @@ -567,9 +566,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): raise LookupError("Y coord {y!r} is greater than total height") return self._y_offsets[y] - def _render_line( - self, y: int, x1: int, x2: int, base_style: Style - ) -> list[Segment]: + def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: """Render a line in to a list of segments. Args: @@ -587,7 +584,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): try: row_index, line_no = self._get_offsets(y) except LookupError: - return [Segment(" " * width, base_style)] + return Strip.blank(width, base_style) cursor_column = ( self.cursor_column if (self.show_cursor and self.cursor_row == row_index) @@ -617,10 +614,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): segments = Segment.adjust_line_length(segments, width, style=base_style) simplified_segments = list(Segment.simplify(segments)) - self._line_cache[cache_key] = simplified_segments - return segments + strip = Strip(simplified_segments, width) + self._line_cache[cache_key] = strip + return strip - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: width, height = self.size scroll_x, scroll_y = self.scroll_offset fixed_top_row_count = sum( diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 0e7df91c1..0bfc8b899 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -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 Strips +from ..strip import Strip class TextLog(ScrollView, can_focus=True): @@ -48,8 +48,8 @@ class TextLog(ScrollView, can_focus=True): super().__init__(name=name, id=id, classes=classes) self.max_lines = max_lines self._start_line: int = 0 - self.lines: list[list[Segment]] = [] - self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] + self.lines: list[Strip] = [] + self._line_cache: LRUCache[tuple[int, int, int, int], Strip] self._line_cache = LRUCache(1024) self.max_width: int = 0 self.min_width = min_width @@ -120,7 +120,8 @@ class TextLog(ScrollView, can_focus=True): self.max_width, max(sum(segment.cell_length for segment in _line) for _line in lines), ) - self.lines.extend(lines) + strips = Strip.from_lines(lines, render_width) + self.lines.extend(strips) if self.max_lines is not None and len(self.lines) > self.max_lines: self._start_line += len(self.lines) - self.max_lines @@ -138,13 +139,13 @@ class TextLog(ScrollView, can_focus=True): self.virtual_size = Size(self.max_width, len(self.lines)) self.refresh() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset line = self._render_line(scroll_y + y, scroll_x, self.size.width) - line = list(Segment.apply_style(line, self.rich_style)) - return line + strip = Strip(Segment.apply_style(line, self.rich_style), self.size.width) + return strip - def render_lines(self, crop: Region) -> Strips: + def render_lines(self, crop: Region) -> list[Strip]: """Render the widget in to lines. Args: @@ -156,19 +157,20 @@ class TextLog(ScrollView, can_focus=True): lines = self._styles_cache.render_widget(self, crop) return lines - def _render_line(self, y: int, scroll_x: int, width: int) -> list[Segment]: + def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: if y >= len(self.lines): - return [Segment(" " * width, self.rich_style)] + return Strip.blank(width, self.rich_style) key = (y + self._start_line, scroll_x, width, self.max_width) if key in self._line_cache: return self._line_cache[key] - line = self.lines[y] - line = Segment.adjust_line_length( - line, max(self.max_width, width), self.rich_style + line = ( + self.lines[y] + .adjust_cell_length(max(self.max_width, width), self.rich_style) + .crop(scroll_x, scroll_x + width) ) - line = line_crop(line, scroll_x, scroll_x + width, self.max_width) + self._line_cache[key] = line return line diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 981911b75..e23385502 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -5,22 +5,21 @@ from typing import ClassVar, Generic, NewType, TypeVar import rich.repr from rich.segment import Segment -from rich.style import Style, NULL_STYLE +from rich.style import NULL_STYLE, Style from rich.text import Text, TextType - -from ..binding import Binding -from ..geometry import clamp, Region, Size -from .._loop import loop_last +from .. import events from .._cache import LRUCache -from ..message import Message -from ..reactive import reactive, var +from .._loop import loop_last from .._segment_tools import line_crop, line_pad from .._types import MessageTarget from .._typing import TypeAlias +from ..binding import Binding +from ..geometry import Region, Size, clamp +from ..message import Message +from ..reactive import reactive, var from ..scroll_view import ScrollView - -from .. import events +from ..strip import Strip NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") @@ -365,7 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._current_id = 0 self.root = self._add_node(None, text_label, data) - self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) + self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None @@ -666,7 +665,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.cursor_line = -1 self.refresh() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: width = self.size.width scroll_x, scroll_y = self.scroll_offset style = self.rich_style @@ -677,14 +676,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): style, ) - def _render_line( - self, y: int, x1: int, x2: int, base_style: Style - ) -> list[Segment]: + def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: tree_lines = self._tree_lines width = self.size.width if y >= len(tree_lines): - return [Segment(" " * width, base_style)] + return Strip.blank(width, base_style) line = tree_lines[y] @@ -699,7 +696,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): tuple(node._updates for node in line.path), ) if cache_key in self._line_cache: - segments = self._line_cache[cache_key] + strip = self._line_cache[cache_key] else: base_guide_style = self.get_component_rich_style( "tree--guides", partial=True @@ -785,11 +782,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): segments = list(guides.render(self.app.console)) pad_width = max(self.virtual_size.width, width) segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style) - self._line_cache[cache_key] = segments + strip = self._line_cache[cache_key] = Strip(segments) - segments = line_crop(segments, x1, x2, Segment.get_line_length(segments)) - - return segments + strip = strip.crop(x1, x2) + return strip def _on_resize(self, event: events.Resize) -> None: self._line_cache.grow(event.size.height) diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 6f65f003f..fdeb72dd6 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -4,14 +4,13 @@ from rich.segment import Segment from rich.style import Style from textual._styles_cache import StylesCache -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: Strips): +def _extract_content(lines: list[list[Segment]]): """Extract the text content from lines.""" content = ["".join(segment.text for segment in line) for line in lines] return content