diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 95b8a3cd6..8b397322a 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -16,7 +16,7 @@ } *:hover { - tint: 30% red; + /* tint: 30% red; /* outline: heavy red; */ } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 3cb288db1..98d186b41 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -115,7 +115,7 @@ class BasicApp(App, css_path="basic.css"): Static(Syntax(CODE, "python"), classes="code"), classes="scrollable", ), - # table, + table, Error(), Tweet(TweetBody(), classes="scrollbar-size-custom"), Warning(), diff --git a/src/textual/_border.py b/src/textual/_border.py index a87a7bd5a..ab3e1ba96 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -131,6 +131,17 @@ def get_box( def render_row( box_row: tuple[Segment, Segment, Segment], width: int, left: bool, right: bool ) -> list[Segment]: + """Render a top, or bottom border row. + + Args: + box_row (tuple[Segment, Segment, Segment]): Corners and side segments. + width (int): Total width of resulting line. + left (bool): Render left corner. + right (bool): Render right corner. + + Returns: + list[Segment]: A list of segments. + """ box1, box2, box3 = box_row if left and right: return [box1, Segment(box2.text * (width - 2), box2.style), box3] diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 5cbcf9c8d..3258d6c5a 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -95,7 +95,7 @@ def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]: def line_pad( segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style -) -> Iterable[Segment]: +) -> list[Segment]: """Adds padding to the left and / or right of a list of segments. Args: @@ -123,4 +123,4 @@ def line_pad( *segments, Segment(" " * pad_right, style), ] - return segments + return list(segments) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py new file mode 100644 index 000000000..da6b6b235 --- /dev/null +++ b/src/textual/_styles_cache.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Callable, Iterable + +from rich.segment import Segment +from rich.style import Style + +from ._border import get_box, render_row +from ._segment_tools import line_crop, line_pad, line_trim +from ._types import Lines +from .color import Color +from .geometry import Region, Size +from .renderables.opacity import Opacity +from .renderables.tint import Tint + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +if TYPE_CHECKING: + from .css.styles import StylesBase + from .widget import Widget + + +RenderLineCallback: TypeAlias = Callable[[int], list[Segment]] + + +class StylesCache: + """Responsible for rendering CSS Styles and keeping a cached of rendered lines. + + ``` + ┏━━━━━━━━━━━━━━━━━━━━━━┓◀── A. border + ┃ ┃◀┐ + ┃ ┃ └─ B. border + padding + + ┃ Lorem ipsum dolor ┃◀┐ border + ┃ sit amet, ┃ │ + ┃ consectetur ┃ └─ C. border + padding + + ┃ adipiscing elit, ┃ content + padding + + ┃ sed do eiusmod ┃ border + ┃ tempor incididunt ┃ + ┃ ┃ + ┃ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━┛ + ``` + + """ + + def __init__(self) -> None: + self._cache: dict[int, list[Segment]] = {} + self._dirty_lines: set[int] = set() + self._width = 1 + + def set_dirty(self, *regions: Region) -> None: + """Add a dirty regions.""" + if regions: + for region in regions: + self._dirty_lines.update(region.line_range) + else: + self.clear() + + def is_dirty(self, y: int) -> bool: + return y in self._dirty_lines + + def clear(self) -> None: + self._cache.clear() + self._dirty_lines.clear() + + def render_widget(self, widget: Widget, crop: Region) -> Lines: + """Render the content for a widget. + + Args: + widget (Widget): A widget. + region (Region): A region of the widget to render. + + Returns: + Lines: Rendered lines. + """ + (base_background, base_color), (background, color) = widget.colors + lines = self.render( + widget.styles, + widget.region.size, + base_background, + background, + widget.render_line, + crop=crop, + ) + return lines + + def render( + self, + styles: StylesBase, + size: Size, + base_background: Color, + background: Color, + render_line: RenderLineCallback, + crop: Region | None = None, + ) -> Lines: + """Render a given region. + + Args: + region (Region): A region in the screen to render. + + Returns: + Lines: List of Segments, one per line. + """ + return self._render( + size, + size.region if crop is None else crop, + styles, + base_background, + background, + render_line, + ) + + def _render( + self, + size: Size, + crop: Region, + styles: StylesBase, + base_background: Color, + background: Color, + render_content_line: RenderLineCallback, + ) -> Lines: + width, height = size + if width != self._width: + self.clear() + self._width = width + lines: Lines = [] + add_line = lines.append + simplify = Segment.simplify + + 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( + styles, y, size, base_background, background, render_content_line + ) + line = list(simplify(line)) + self._cache[y] = line + else: + line = self._cache[y] + add_line(line) + 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] + + return lines + + def render_line( + self, + styles: StylesBase, + y: int, + size: Size, + base_background: Color, + background: Color, + render_content_line: RenderLineCallback, + ) -> list[Segment]: + """Render a styled lines. + + Args: + styles (RenderStyles): Styles object. + y (int): The y coordinate of the line (relative to top of widget) + size (Size): Size of the widget. + base_background (Color): The background color beneath this widget. + background (Color): Background color of the widget. + + Returns: + list[Segment]: A line as a list of segments. + """ + + gutter = styles.gutter + width, height = size + + pad_top, pad_right, pad_bottom, pad_left = styles.padding + ( + (border_top, border_top_color), + (border_right, border_right_color), + (border_bottom, border_bottom_color), + (border_left, border_left_color), + ) = styles.border + + ( + (outline_top, outline_top_color), + (outline_right, outline_right_color), + (outline_bottom, outline_bottom_color), + (outline_left, outline_left_color), + ) = styles.outline + + from_color = Style.from_color + + rich_style = styles.rich_style + inner = from_color(bgcolor=background.rich_color) + rich_style + outer = from_color(bgcolor=base_background.rich_color) + + def post(segments: Iterable[Segment]) -> list[Segment]: + """Post process segments to apply opacity and tint. + + Args: + segments (Iterable[Segment]): Iterable of segments. + + Returns: + list[Segment]: New list of segments + """ + if styles.opacity != 1.0: + segments = Opacity.process_segments(segments, styles.opacity) + if styles.tint.a: + segments = Tint.process_segments(segments, styles.tint) + return segments if isinstance(segments, list) else list(segments) + + line: Iterable[Segment] + # Draw top or bottom borders (A) + if (border_top and y == 0) or (border_bottom and y == height - 1): + border_color = border_top_color if y == 0 else border_bottom_color + box_segments = get_box( + border_top if y == 0 else border_bottom, + inner, + outer, + from_color(color=border_color.rich_color), + ) + line = render_row( + box_segments[0 if y == 0 else 2], + width, + border_left != "", + border_right != "", + ) + + # Draw padding (B) + elif (pad_top and y < gutter.top) or ( + pad_bottom and y >= height - gutter.bottom + ): + background_style = from_color( + color=rich_style.color, bgcolor=background.rich_color + ) + left_style = from_color(color=border_left_color.rich_color) + left = get_box(border_left, inner, outer, left_style)[1][0] + right_style = from_color(color=border_right_color.rich_color) + right = get_box(border_right, inner, outer, right_style)[1][2] + if border_left and border_right: + line = [left, Segment(" " * (width - 2), background_style), right] + elif border_left: + line = [left, Segment(" " * (width - 1), background_style)] + elif border_right: + line = [Segment(" " * (width - 1), background_style), right] + else: + line = [Segment(" " * width, background_style)] + else: + # Content with border and padding (C) + line = render_content_line(y - gutter.top) + if inner: + line = Segment.apply_style(line, inner) + line = line_pad(line, pad_left, pad_right, inner) + + if border_left or border_right: + # Add left / right border + left_style = from_color(border_left_color.rich_color) + left = get_box(border_left, inner, outer, left_style)[1][0] + right_style = from_color(border_right_color.rich_color) + right = get_box(border_right, inner, outer, right_style)[1][2] + + if border_left and border_right: + line = [left, *line, right] + elif border_left: + line = [left, *line] + else: + line = [*line, right] + + # Draw any outline + if (outline_top and y == 0) or (outline_bottom and y == height - 1): + # Top or bottom outlines + outline_color = outline_top_color if y == 0 else outline_bottom_color + box_segments = get_box( + outline_top if y == 0 else outline_bottom, + inner, + outer, + from_color(color=outline_color.rich_color), + ) + line = render_row( + box_segments[0 if y == 0 else 2], + width, + outline_left != "", + outline_right != "", + ) + + elif outline_left or outline_right: + # Lines in side outline + left_style = from_color(outline_left_color.rich_color) + left = get_box(outline_left, inner, outer, left_style)[1][0] + right_style = from_color(outline_right_color.rich_color) + right = get_box(outline_right, inner, outer, right_style)[1][2] + line = line_trim(list(line), outline_left != "", outline_right != "") + if outline_left and outline_right: + line = [left, *line, right] + elif outline_left: + line = [left, *line] + else: + line = [*line, right] + + return post(line) diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py deleted file mode 100644 index fb7fed1f0..000000000 --- a/src/textual/_styles_render.py +++ /dev/null @@ -1,260 +0,0 @@ -from __future__ import annotations - -from rich.style import Style -from typing import Iterable, TYPE_CHECKING - -from rich.segment import Segment - -from ._border import get_box, render_row -from .color import Color -from .css.types import EdgeType -from .renderables.opacity import Opacity -from .renderables.tint import Tint -from ._segment_tools import line_crop, line_pad, line_trim -from ._types import Lines -from .geometry import Region, Size - - -if TYPE_CHECKING: - from .css.styles import RenderStyles - from .widget import Widget - - -class StylesRenderer: - """Responsible for rendering CSS Styles and keeping a cached of rendered lines.""" - - def __init__(self, widget: Widget) -> None: - self._widget = widget - self._cache: dict[int, list[Segment]] = {} - self._dirty_lines: set[int] = set() - - def set_dirty(self, *regions: Region) -> None: - """Add a dirty region, or set the entire widget as dirty.""" - if regions: - for region in regions: - self._dirty_lines.update(region.line_range) - else: - self._dirty_lines.clear() - self._dirty_lines.update(self._widget.size.lines_range) - - def render(self, region: Region) -> Lines: - - widget = self._widget - styles = widget.styles - size = widget.size - (base_background, base_color), (background, color) = widget.colors - - return self._render(size, region, styles, base_background, background) - - def _render( - self, - size: Size, - region: Region, - styles: RenderStyles, - base_background: Color, - background: Color, - ): - width, height = size - lines: Lines = [] - add_line = lines.append - simplify = Segment.simplify - - is_dirty = self._dirty_lines.__contains__ - render_line = self.render_line - for y in region.line_range: - if is_dirty(y) or y not in self._cache: - line = render_line(styles, y, size, base_background, background) - line = list(simplify(line)) - self._cache[y] = line - else: - line = self._cache[y] - add_line(line) - self._dirty_lines.difference_update(region.line_range) - - if region.column_span != (0, width): - _line_crop = line_crop - x1, x2 = region.column_span - lines = [_line_crop(line, x1, x2, width) for line in lines] - - return lines - - def render_content_line(self, y: int) -> list[Segment]: - return self._widget.render_line(y) - - def render_line( - self, - styles: RenderStyles, - y: int, - size: Size, - base_background: Color, - background: Color, - ) -> list[Segment]: - - gutter = styles.gutter - width, height = size - - pad_top, pad_right, pad_bottom, pad_left = styles.padding - ( - (border_top, border_top_color), - (border_right, border_right_color), - (border_bottom, border_bottom_color), - (border_left, border_left_color), - ) = styles.border - - ( - (outline_top, outline_top_color), - (outline_right, outline_right_color), - (outline_bottom, outline_bottom_color), - (outline_left, outline_left_color), - ) = styles.outline - - from_color = Style.from_color - - rich_style = styles.rich_style - inner_style = from_color(bgcolor=background.rich_color) + rich_style - outer_style = from_color(bgcolor=base_background.rich_color) - - def post(segments: Iterable[Segment]) -> list[Segment]: - if styles.opacity != 1.0: - segments = Opacity.process_segments(segments, styles.opacity) - if styles.tint.a: - segments = Tint.process_segments(segments, styles.tint) - return segments if isinstance(segments, list) else list(segments) - - line: Iterable[Segment] - # Draw top or bottom borders - if (border_top and y == 0) or (border_bottom and y == height - 1): - - border_color = border_top_color if y == 0 else border_bottom_color - box_segments = get_box( - border_top if y == 0 else border_bottom, - inner_style, - outer_style, - from_color(color=border_color.rich_color), - ) - line = render_row( - box_segments[0 if y == 0 else 2], - width, - border_left != "", - border_right != "", - ) - - # Draw padding - elif (pad_top and y < gutter.top) or ( - pad_bottom and y >= height - gutter.bottom - ): - background_style = from_color( - color=rich_style.color, bgcolor=background.rich_color - ) - _, (left, _, _), _ = get_box( - border_left, - inner_style, - outer_style, - from_color(color=border_left_color.rich_color), - ) - _, (_, _, right), _ = get_box( - border_right, - inner_style, - outer_style, - from_color(color=border_right_color.rich_color), - ) - if border_left and border_right: - line = [left, Segment(" " * (width - 2), background_style), right] - if border_left: - line = [left, Segment(" " * (width - 1), background_style)] - if border_right: - line = [Segment(" " * (width - 1), background_style), right] - else: - line = [Segment(" " * width, background_style)] - else: - # Apply background style - line = self.render_content_line(y - gutter.top) - if inner_style: - line = Segment.apply_style(line, inner_style) - line = line_pad(line, pad_left, pad_right, inner_style) - - if border_left or border_right: - # Add left / right border - _, (left, _, _), _ = get_box( - border_left, - inner_style, - outer_style, - from_color(border_left_color.rich_color), - ) - _, (_, _, right), _ = get_box( - border_right, - inner_style, - outer_style, - from_color(border_right_color.rich_color), - ) - - if border_left and border_right: - line = [left, *line, right] - elif border_left: - line = [left, *line] - else: - line = [*line, right] - - if (outline_top and y == 0) or (outline_bottom and y == height - 1): - outline_color = outline_top_color if y == 0 else outline_bottom_color - box_segments = get_box( - outline_top if y == 0 else outline_bottom, - inner_style, - outer_style, - from_color(color=outline_color.rich_color), - ) - line = render_row( - box_segments[0 if y == 0 else 2], - width, - outline_left != "", - outline_right != "", - ) - - elif outline_left or outline_right: - _, (left, _, _), _ = get_box( - outline_left, - inner_style, - outer_style, - from_color(outline_left_color.rich_color), - ) - _, (_, _, right), _ = get_box( - outline_right, - inner_style, - outer_style, - from_color(outline_right_color.rich_color), - ) - line = line_trim(list(line), outline_left != "", outline_right != "") - if outline_left and outline_right: - line = [left, *line, right] - elif outline_left: - line = [left, *line] - else: - line = [*line, right] - - return post(line) - - -if __name__ == "__main__": - - from rich import print - from .css.styles import Styles - - styles = Styles() - styles.padding = 2 - styles.border = ( - ("tall", Color.parse("red")), - ("none", Color.parse("white")), - ("outer", Color.parse("red")), - ("none", Color.parse("red")), - ) - - size = Size(40, 10) - sr = StylesRenderer(None) - lines = sr._render( - size, size.region, styles, Color.parse("blue"), Color.parse("green") - ) - for line in lines: - print(line) - from rich.segment import SegmentLines - - print(SegmentLines(lines, new_lines=True)) diff --git a/src/textual/color.py b/src/textual/color.py index b7dcac440..2647ba80b 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -6,7 +6,7 @@ exception should be when passing things to a Rich renderable, which will need to `rich_color` attribute to perform a conversion. I'm not entirely happy with burdening the user with two similar color classes. In a future -update we might add a protocol to convert automatically so the dev could use them interchangably. +update we might add a protocol to convert automatically so the dev could use them interchangeably. """ diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 3fa4d3a30..102e7eb42 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"} VALID_BORDER: Final[set[EdgeType]] = { "none", "hidden", + "ascii", "round", "blank", "solid", diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 34543e5b0..5a5ab84df 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -261,12 +261,6 @@ class StylesBase(ABC): spacing = self.padding + self.border.spacing return spacing - @property - def content_gutter(self) -> Spacing: - """The spacing that surrounds the content area of the widget.""" - spacing = self.padding + self.border.spacing + self.margin - return spacing - @property def auto_dimensions(self) -> bool: """Check if width or height are set to 'auto'.""" diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 09454411e..4bca0da80 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -140,7 +140,8 @@ class Size(NamedTuple): return Region(0, 0, width, height) @property - def lines_range(self) -> range: + def line_range(self) -> range: + """Get a range covering lines.""" return range(self.height) def __add__(self, other: object) -> Size: diff --git a/src/textual/widget.py b/src/textual/widget.py index e9d44ffaf..eea289c6c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2,12 +2,12 @@ from __future__ import annotations from fractions import Fraction from typing import ( + TYPE_CHECKING, Any, Awaitable, + Callable, ClassVar, Collection, - TYPE_CHECKING, - Callable, Iterable, NamedTuple, ) @@ -20,37 +20,32 @@ from rich.padding import Padding from rich.segment import Segment from rich.style import Style -from . import errors -from . import events +from . import errors, events, messages from ._animator import BoundAnimator from ._border import Border -from .box_model import BoxModel, get_box_model from ._context import active_app -from ._styles_render import StylesRenderer +from ._layout import ArrangeResult, Layout +from ._segment_tools import line_crop +from ._styles_cache import StylesCache from ._types import Lines +from .box_model import BoxModel, get_box_model from .dom import DOMNode -from ._layout import ArrangeResult -from .geometry import clamp, Offset, Region, Size, Spacing +from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message -from . import messages -from ._layout import Layout from .reactive import Reactive, watch from .renderables.opacity import Opacity from .renderables.tint import Tint -from ._segment_tools import line_crop -from .css.styles import Styles - if TYPE_CHECKING: from .app import App, ComposeResult from .scrollbar import ( ScrollBar, - ScrollTo, - ScrollUp, ScrollDown, ScrollLeft, ScrollRight, + ScrollTo, + ScrollUp, ) @@ -120,7 +115,7 @@ class Widget(DOMNode): self._arrangement: ArrangeResult | None = None self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) - self._styles_renderer = StylesRenderer(self) + self._styles_cache = StylesCache() super().__init__(name=name, id=id, classes=classes) self.add_children(*children) @@ -443,9 +438,14 @@ class Widget(DOMNode): """ if regions: - self._dirty_regions.update(regions) + widget_regions = [ + region.translate(self.content_offset) for region in regions + ] + self._dirty_regions.update(widget_regions) + self._styles_cache.set_dirty(*widget_regions) else: self._dirty_regions.clear() + self._styles_cache.clear() # TODO: Does this need to be content region? # self._dirty_regions.append(self.size.region) self._dirty_regions.add(self.size.region) @@ -668,7 +668,7 @@ class Widget(DOMNode): bool: True if the window was scrolled. """ - window = self.region.at_offset(self.scroll_offset) + window = self.content_region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) delta = Region.get_scroll_to_visible(window, region) @@ -847,7 +847,7 @@ class Widget(DOMNode): @property def content_size(self) -> Size: - return self._size - self.styles.gutter.totals + return self.content_region.size @property def size(self) -> Size: @@ -860,12 +860,12 @@ class Widget(DOMNode): @property def content_region(self) -> Region: """Gets an absolute region containing the content (minus padding and border).""" - return self.region.shrink(self.styles.content_gutter) + return self.region.shrink(self.styles.gutter) @property def content_offset(self) -> Offset: """An offset from the Widget origin where the content begins.""" - x, y = self.styles.content_gutter.top_left + x, y = self.styles.gutter.top_left return Offset(x, y) @property @@ -998,15 +998,9 @@ class Widget(DOMNode): Lines: A list of list of segments """ if self._dirty_regions: - self._styles_renderer.set_dirty(*self._dirty_regions) self._render_lines() - lines = self._styles_renderer.render(crop) - return lines - - x1, y1, x2, y2 = crop.corners - lines = self._render_cache.lines[y1:y2] - lines = self._crop_lines(lines, x1, x2) + lines = self._styles_cache.render_widget(self, crop) return lines def get_style_at(self, x: int, y: int) -> Style: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 31782bc8a..5cf4cc068 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -213,6 +213,7 @@ class DataTable(ScrollView, Generic[CellType]): self._row_render_cache.clear() self._cell_render_cache.clear() self._line_cache.clear() + self._styles_cache.clear() def get_row_height(self, row_index: int) -> int: if row_index == -1: @@ -264,7 +265,8 @@ class DataTable(ScrollView, Generic[CellType]): y = row.y if self.show_header: y += self.header_height - return Region(x, y, width, height) + cell_region = Region(x, y, width, height) + return cell_region def add_column(self, label: TextType, *, width: int = 10) -> None: """Add a column to the table. @@ -460,7 +462,7 @@ class DataTable(ScrollView, Generic[CellType]): list[Segment]: List of segments for rendering. """ - width = self.region.width + width = self.content_size.width try: row_index, line_no = self._get_offsets(y) @@ -496,38 +498,58 @@ class DataTable(ScrollView, Generic[CellType]): self._line_cache[cache_key] = simplified_segments return segments - def render_lines(self, crop: Region) -> Lines: - """Render lines within a given region. - - Args: - crop (Region): Region to crop to. - - Returns: - Lines: A list of segments for every line within crop region. - """ - scroll_y = self.scroll_offset.y - x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners - - base_style = self.rich_style - + def render_line(self, y: int) -> list[Segment]: + width, height = self.content_size + scroll_x, scroll_y = self.scroll_offset fixed_top_row_count = sum( self.get_row_height(row_index) for row_index in range(self.fixed_rows) ) if self.show_header: fixed_top_row_count += self.get_row_height(-1) - render_line = self._render_line - fixed_lines = [ - render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count) - ] - lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)] + style = self.rich_style - for line_index, y in enumerate(range(y1, y2)): - if y - scroll_y < fixed_top_row_count: - lines[line_index] = fixed_lines[line_index] + if y >= fixed_top_row_count: + y += scroll_y + return self._render_line(y, scroll_x, scroll_x + width, style) + + def render_lines(self, crop: Region) -> Lines: + lines = self._styles_cache.render_widget(self, crop) return lines + # def render_lines(self, crop: Region) -> Lines: + # """Render lines within a given region. + + # Args: + # crop (Region): Region to crop to. + + # Returns: + # Lines: A list of segments for every line within crop region. + # """ + # scroll_y = self.scroll_offset.y + # x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners + + # base_style = self.rich_style + + # fixed_top_row_count = sum( + # self.get_row_height(row_index) for row_index in range(self.fixed_rows) + # ) + # if self.show_header: + # fixed_top_row_count += self.get_row_height(-1) + + # render_line = self._render_line + # fixed_lines = [ + # render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count) + # ] + # lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)] + + # for line_index, y in enumerate(range(y1, y2)): + # if y - scroll_y < fixed_top_row_count: + # lines[line_index] = fixed_lines[line_index] + + # return lines + def on_mouse_move(self, event: events.MouseMove): meta = event.style.meta if meta: @@ -551,6 +573,7 @@ class DataTable(ScrollView, Generic[CellType]): def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: region = self._get_cell_region(self.cursor_row, self.cursor_column) + region.translate(self.content_offset) spacing = self._get_cell_border() self.scroll_to_region(region, animate=animate, spacing=spacing) diff --git a/tests/test_border.py b/tests/test_border.py new file mode 100644 index 000000000..4e2d56390 --- /dev/null +++ b/tests/test_border.py @@ -0,0 +1,25 @@ +from rich.segment import Segment +from rich.style import Style + +from textual._border import get_box, render_row + + +def test_border_render_row(): + + style = Style.parse("red") + row = (Segment("┏", style), Segment("━", style), Segment("┓", style)) + + assert render_row(row, 5, False, False) == [Segment(row[1].text * 5, row[1].style)] + assert render_row(row, 5, True, False) == [ + row[0], + Segment(row[1].text * 4, row[1].style), + ] + assert render_row(row, 5, False, True) == [ + Segment(row[1].text * 4, row[1].style), + row[2], + ] + assert render_row(row, 5, True, True) == [ + row[0], + Segment(row[1].text * 3, row[1].style), + row[2], + ] diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index 520ca634d..770f33e23 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -2,7 +2,7 @@ from rich.segment import Segment from rich.style import Style -from textual._segment_tools import line_crop, line_trim +from textual._segment_tools import line_crop, line_trim, line_pad def test_line_crop(): @@ -89,3 +89,25 @@ def test_line_trim(): ] assert line_trim([], True, True) == [] + + +def test_line_pad(): + segments = [Segment("foo"), Segment("bar")] + style = Style.parse("red") + assert line_pad(segments, 2, 3, style) == [ + Segment(" ", style), + *segments, + Segment(" ", style), + ] + + assert line_pad(segments, 0, 3, style) == [ + *segments, + Segment(" ", style), + ] + + assert line_pad(segments, 2, 0, style) == [ + Segment(" ", style), + *segments, + ] + + assert line_pad(segments, 0, 0, style) == segments diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py new file mode 100644 index 000000000..b927c6429 --- /dev/null +++ b/tests/test_styles_cache.py @@ -0,0 +1,260 @@ +from rich.segment import Segment + +from textual.color import Color +from textual.geometry import Region, Size +from textual.css.styles import Styles +from textual._styles_cache import StylesCache +from textual._types import Lines + + +def _extract_content(lines: Lines): + """Extract the text content from lines.""" + content = ["".join(segment.text for segment in line) for line in lines] + return content + + +def test_set_dirty(): + cache = StylesCache() + cache.set_dirty(Region(3, 4, 10, 2)) + assert not cache.is_dirty(3) + assert cache.is_dirty(4) + assert cache.is_dirty(5) + assert not cache.is_dirty(6) + + +def test_no_styles(): + """Test that empty style returns the content un-altered""" + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + cache = StylesCache() + lines = cache.render( + styles, + Size(3, 3), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + ) + expected = [ + [Segment("foo", styles.rich_style)], + [Segment("bar", styles.rich_style)], + [Segment("baz", styles.rich_style)], + ] + assert lines == expected + + +def test_border(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(5, 5), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + ) + + text_content = _extract_content(lines) + + expected_text = [ + "┏━━━┓", + "┃foo┃", + "┃bar┃", + "┃baz┃", + "┗━━━┛", + ] + + assert text_content == expected_text + + +def test_padding(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + cache = StylesCache() + lines = cache.render( + styles, + Size(5, 5), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + ) + + text_content = _extract_content(lines) + + expected_text = [ + " ", + " foo ", + " bar ", + " baz ", + " ", + ] + + assert text_content == expected_text + + +def test_padding_border(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + ) + + text_content = _extract_content(lines) + + expected_text = [ + "┏━━━━━┓", + "┃ ┃", + "┃ foo ┃", + "┃ bar ┃", + "┃ baz ┃", + "┃ ┃", + "┗━━━━━┛", + ] + + assert text_content == expected_text + + +def test_outline(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.outline = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(3, 3), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + ) + + text_content = _extract_content(lines) + expected_text = [ + "┏━┓", + "┃a┃", + "┗━┛", + ] + assert text_content == expected_text + + +def test_crop(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + crop=Region(2, 2, 3, 3), + ) + text_content = _extract_content(lines) + expected_text = [ + "foo", + "bar", + "baz", + ] + assert text_content == expected_text + + +def test_dirty_cache(): + """Check that we only render content once or if it has been marked as dirty.""" + + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + rendered_lines: list[int] = [] + + def get_content_line(y: int) -> list[Segment]: + rendered_lines.append(y) + return content[y] + + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + ) + assert rendered_lines == [0, 1, 2] + del rendered_lines[:] + + text_content = _extract_content(lines) + expected_text = [ + "┏━━━━━┓", + "┃ ┃", + "┃ foo ┃", + "┃ bar ┃", + "┃ baz ┃", + "┃ ┃", + "┗━━━━━┛", + ] + assert text_content == expected_text + + # Re-render styles, check that content was not requested + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + ) + assert rendered_lines == [] + del rendered_lines[:] + text_content = _extract_content(lines) + assert text_content == expected_text + + # Mark 2 lines as dirty + cache.set_dirty(Region(0, 2, 7, 2)) + + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + ) + assert rendered_lines == [0, 1] + text_content = _extract_content(lines) + assert text_content == expected_text