From c723cbd3562267b40f0af7613a7c34a7a8bf756b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 29 Jun 2022 15:46:06 +0100 Subject: [PATCH 01/18] Styles renderer --- src/textual/_styles_render.py | 196 ++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/textual/_styles_render.py diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py new file mode 100644 index 000000000..a28b4c2a6 --- /dev/null +++ b/src/textual/_styles_render.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from rich.style import Style +from typing import TYPE_CHECKING + +from rich.segment import Segment + +from ._border import get_box +from .color import Color +from ._segment_tools import line_crop +from ._types import Lines +from .geometry import Region, Size +from .widget import Widget + + +if TYPE_CHECKING: + from .css.styles import RenderStyles + + +class StylesRenderer: + def __init__(self, widget: Widget) -> None: + self._widget = widget + self._cache: dict[int, list[Segment]] = {} + self._dirty_lines: set[int] = set() + + def invalidate(self, region: Region) -> None: + self._dirty_lines.update(region.y_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 + + is_dirty = self._dirty_lines.__contains__ + render_line = self.render_line + for y in region.y_range: + if is_dirty(y) or y not in self._cache: + line = render_line(styles, y, size, base_background, background) + self._cache[y] = line + else: + line = self._cache[y] + add_line(line) + self._dirty_lines.difference_update(region.y_range) + + if region.x_extents != (0, width): + _line_crop = line_crop + x1, x2 = region.x_range + lines = [_line_crop(line, x1, x2, width) for line in lines] + + return lines + + def render_content_line(self, y: int, width: int) -> list[Segment]: + return [Segment((str(y) * width)[:width])] + + def render_line( + self, + styles: RenderStyles, + y: int, + size: Size, + base_background: Color, + background: Color, + ) -> list[Segment]: + + gutter = styles.gutter + width, height = size + content_width, content_height = size - gutter.totals + last_y = height - 1 + + 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 + + inner_style = Style.from_color(base_background.rich_color) + outer_style = Style.from_color(background.rich_color) + + if border_top and y in (0, last_y): + + 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, + Style.from_color(bgcolor=border_color.rich_color), + ) + box1, box2, box3 = box_segments[0 if y == 0 else 2] + + if border_left and border_right: + return [box1, Segment(box2.text * (width - 2), box2.style), box3] + elif border_left: + return [box1, Segment(box2.text * (width - 1), box2.style)] + return [Segment(box2.text * (width - 1), box2.style), box3] + + if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): + background_style = Style.from_color(bgcolor=base_background.rich_color) + _, (left, _, _), _ = get_box( + border_left, + inner_style, + outer_style, + Style.from_color(bgcolor=border_left_color.rich_color), + ) + _, (_, _, right), _ = get_box( + border_left, + inner_style, + outer_style, + Style.from_color(bgcolor=border_right_color.rich_color), + ) + if border_left and border_right: + return [left, Segment(" " * (width - 2), background_style), right] + if border_left: + return [left, Segment(" " * (width - 1), background_style)] + if border_right: + return [Segment(" " * (width - 1), background_style), right] + return [Segment(" " * width, background_style)] + + line_y = y - gutter.top + line = self.render_content_line(line_y, content_width) + + if pad_left and pad_right: + line = [ + Segment(" " * pad_left, inner_style), + *line, + Segment(" " * pad_right, inner_style), + ] + elif pad_left: + line = [ + Segment(" " * pad_left, inner_style), + *line, + ] + elif pad_right: + line = [ + *line, + Segment(" " * pad_right, inner_style), + ] + + if not border_left and not border_right: + return line + + _, (left, _, _), _ = get_box( + border_left, + inner_style, + outer_style, + Style.from_color(border_left_color.rich_color), + ) + _, (_, _, right), _ = get_box( + border_left, + inner_style, + outer_style, + Style.from_color(border_right_color.rich_color), + ) + + if border_left and border_right: + return [left, *line, right] + elif border_left: + return [left, *line] + + return [right, *line] + + +if __name__ == "__main__": + + from rich import print + from .css.styles import Styles + + styles = Styles() + styles.padding = 2 + styles.border = ("solid", Color.parse("red")) + + size = Size(40, 10) + sr = StylesRenderer(None) + lines = sr._render( + size, size.region, styles, Color.parse("blue"), Color.parse("green") + ) + + from rich.segment import SegmentLines + + print(SegmentLines(lines, new_lines=True)) From 5c1b50fe43c003a117f0e697360e28c8f2c8d156 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 29 Jun 2022 16:59:21 +0100 Subject: [PATCH 02/18] styles renderer --- src/textual/_styles_render.py | 49 +++++++++++++++++++--------- src/textual/css/_style_properties.py | 2 +- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index a28b4c2a6..c311a5938 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -80,7 +80,6 @@ class StylesRenderer: gutter = styles.gutter width, height = size content_width, content_height = size - gutter.totals - last_y = height - 1 pad_top, pad_right, pad_bottom, pad_left = styles.padding ( @@ -90,17 +89,20 @@ class StylesRenderer: (border_left, border_left_color), ) = styles.border - inner_style = Style.from_color(base_background.rich_color) - outer_style = Style.from_color(background.rich_color) + from_color = Style.from_color - if border_top and y in (0, last_y): + inner_style = from_color(bgcolor=background.rich_color) + outer_style = from_color(bgcolor=base_background.rich_color) + + # Draw top or bottom borders + if border_top and y in (0, 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, - Style.from_color(bgcolor=border_color.rich_color), + Style.from_color(color=border_color.rich_color), ) box1, box2, box3 = box_segments[0 if y == 0 else 2] @@ -108,21 +110,25 @@ class StylesRenderer: return [box1, Segment(box2.text * (width - 2), box2.style), box3] elif border_left: return [box1, Segment(box2.text * (width - 1), box2.style)] - return [Segment(box2.text * (width - 1), box2.style), box3] + elif border_right: + return [Segment(box2.text * (width - 1), box2.style), box3] + else: + return [Segment(box2.text * width, box2.style)] + # Draw padding if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): - background_style = Style.from_color(bgcolor=base_background.rich_color) + background_style = from_color(bgcolor=background.rich_color) _, (left, _, _), _ = get_box( border_left, inner_style, outer_style, - Style.from_color(bgcolor=border_left_color.rich_color), + from_color(color=border_left_color.rich_color), ) _, (_, _, right), _ = get_box( - border_left, + border_right, inner_style, outer_style, - Style.from_color(bgcolor=border_right_color.rich_color), + Style.from_color(color=border_right_color.rich_color), ) if border_left and border_right: return [left, Segment(" " * (width - 2), background_style), right] @@ -132,9 +138,15 @@ class StylesRenderer: return [Segment(" " * (width - 1), background_style), right] return [Segment(" " * width, background_style)] + # Apply background style line_y = y - gutter.top - line = self.render_content_line(line_y, content_width) + line = list( + Segment.apply_style( + self.render_content_line(line_y, content_width), inner_style + ) + ) + # Add padding if pad_left and pad_right: line = [ Segment(" " * pad_left, inner_style), @@ -155,17 +167,18 @@ class StylesRenderer: if not border_left and not border_right: return line + # Add left / right border _, (left, _, _), _ = get_box( border_left, inner_style, outer_style, - Style.from_color(border_left_color.rich_color), + from_color(border_left_color.rich_color), ) _, (_, _, right), _ = get_box( border_left, inner_style, outer_style, - Style.from_color(border_right_color.rich_color), + from_color(border_right_color.rich_color), ) if border_left and border_right: @@ -183,14 +196,20 @@ if __name__ == "__main__": styles = Styles() styles.padding = 2 - styles.border = ("solid", Color.parse("red")) + styles.border = ( + ("outer", Color.parse("red")), + ("", Color.parse("white")), + ("outer", Color.parse("red")), + ("", 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/css/_style_properties.py b/src/textual/css/_style_properties.py index 541235e5a..99bf6f50c 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -397,8 +397,8 @@ class BorderProperty: _border1, _border2, _border3, _border4 = ( normalize_border_value(border[0]), normalize_border_value(border[1]), + normalize_border_value(border[2]), normalize_border_value(border[3]), - normalize_border_value(border[4]), ) setattr(obj, top, _border1) setattr(obj, right, _border2) From ea29604c29d7b32b724d0b4db994951f9a672c27 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 29 Jun 2022 17:23:51 +0100 Subject: [PATCH 03/18] normalize border --- src/textual/_border.py | 2 ++ src/textual/_styles_render.py | 19 ++++++++++++++----- src/textual/css/types.py | 1 + 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index 97a5ecd8b..576742413 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -26,6 +26,7 @@ BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = { # - 2nd string represents (mid1, mid2, mid3) # - 3rd string represents (bottom1, bottom2, bottom3) "": (" ", " ", " "), + "ascii": ("+-+", "| |", "+-+"), "none": (" ", " ", " "), "hidden": (" ", " ", " "), "blank": (" ", " ", " "), @@ -48,6 +49,7 @@ BORDER_LOCATIONS: dict[ EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]] ] = { "": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "ascii": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), "none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), "hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), "blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index c311a5938..1803d419b 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -7,6 +7,7 @@ from rich.segment import Segment from ._border import get_box from .color import Color +from .css.types import EdgeType from ._segment_tools import line_crop from ._types import Lines from .geometry import Region, Size @@ -17,6 +18,9 @@ if TYPE_CHECKING: from .css.styles import RenderStyles +NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""} + + class StylesRenderer: def __init__(self, widget: Widget) -> None: self._widget = widget @@ -89,6 +93,12 @@ class StylesRenderer: (border_left, border_left_color), ) = styles.border + normalize_border_get = NORMALIZE_BORDER.get + border_top = normalize_border_get(border_top, border_top) + border_right = normalize_border_get(border_right, border_right) + border_bottom = normalize_border_get(border_bottom, border_bottom) + border_left = normalize_border_get(border_left, border_left) + from_color = Style.from_color inner_style = from_color(bgcolor=background.rich_color) @@ -139,10 +149,9 @@ class StylesRenderer: return [Segment(" " * width, background_style)] # Apply background style - line_y = y - gutter.top line = list( Segment.apply_style( - self.render_content_line(line_y, content_width), inner_style + self.render_content_line(y - gutter.top, content_width), inner_style ) ) @@ -197,10 +206,10 @@ if __name__ == "__main__": styles = Styles() styles.padding = 2 styles.border = ( + ("tall", Color.parse("red")), + ("none", Color.parse("white")), ("outer", Color.parse("red")), - ("", Color.parse("white")), - ("outer", Color.parse("red")), - ("", Color.parse("red")), + ("none", Color.parse("red")), ) size = Size(40, 10) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index e7fd749b2..f353d5580 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -14,6 +14,7 @@ else: Edge = Literal["top", "right", "bottom", "left"] EdgeType = Literal[ "", + "ascii", "none", "hidden", "blank", From b2f0dbb8a2f0acfba088c1ad37a2f51492865832 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 29 Jun 2022 20:42:55 +0100 Subject: [PATCH 04/18] added line_trim --- src/textual/_segment_tools.py | 32 +++++++++++++++++++++++++++++++- tests/test_segment_tools.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 75a235d8a..22f1cfee6 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -9,7 +9,9 @@ from rich.segment import Segment from ._cells import cell_len -def line_crop(segments: list[Segment], start: int, end: int, total: int): +def line_crop( + segments: list[Segment], start: int, end: int, total: int +) -> list[Segment]: """Crops a list of segments between two cell offsets. Args: @@ -58,3 +60,31 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int): segment = next(iter_segments, None) return output_segments + + +def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]: + """Optionally remove a cell from the start and / or end of a list of segments. + + Args: + segments (list[Segment]): A line (list of Segments) + start (bool): Remove cell from start. + end (bool): Remove cell from end. + + Returns: + list[Segment]: A new list of segments. + """ + segments = segments.copy() + if segments and start: + _, first_segment = segments[0].split_cells(1) + if first_segment.text: + segments[0] = first_segment + else: + segments.pop(0) + if segments and end: + last_segment = segments[-1] + last_segment, _ = last_segment.split_cells(len(last_segment.text) - 1) + if last_segment.text: + segments[-1] = last_segment + else: + segments.pop(-1) + return segments diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index 15347dcd6..520ca634d 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 +from textual._segment_tools import line_crop, line_trim def test_line_crop(): @@ -62,3 +62,30 @@ def test_line_crop_edge_2(): expected = [] print(repr(result)) assert result == expected + + +def test_line_trim(): + segments = [Segment("foo")] + + assert line_trim(segments, False, False) == segments + assert line_trim(segments, True, False) == [Segment("oo")] + assert line_trim(segments, False, True) == [Segment("fo")] + assert line_trim(segments, True, True) == [Segment("o")] + + fob_segments = [Segment("f"), Segment("o"), Segment("b")] + + assert line_trim(fob_segments, True, False) == [ + Segment("o"), + Segment("b"), + ] + + assert line_trim(fob_segments, False, True) == [ + Segment("f"), + Segment("o"), + ] + + assert line_trim(fob_segments, True, True) == [ + Segment("o"), + ] + + assert line_trim([], True, True) == [] From 410fc91a0e39318d5798a1dfec39a8585f570aba Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 30 Jun 2022 10:54:37 +0100 Subject: [PATCH 05/18] styles renderer update --- sandbox/will/basic.py | 2 +- src/textual/_segment_tools.py | 4 ++-- src/textual/_styles_render.py | 32 +++++++++++++++++++------------- src/textual/geometry.py | 4 ++++ src/textual/widget.py | 29 +++++++++++++++++++++++++---- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 3e5f02732..a1a9fc2bb 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/_segment_tools.py b/src/textual/_segment_tools.py index 22f1cfee6..a9bccb699 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -35,7 +35,7 @@ def line_crop( for segment in iter_segments: end_pos = pos + _cell_len(segment.text) if end_pos > start: - segment = segment.split_cells(start - pos)[-1] + segment = segment.split_cells(start - pos)[1] break pos = end_pos else: @@ -86,5 +86,5 @@ def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]: if last_segment.text: segments[-1] = last_segment else: - segments.pop(-1) + segments.pop() return segments diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index 1803d419b..bdc437b76 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -11,11 +11,11 @@ from .css.types import EdgeType from ._segment_tools import line_crop from ._types import Lines from .geometry import Region, Size -from .widget import Widget if TYPE_CHECKING: from .css.styles import RenderStyles + from .widget import Widget NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""} @@ -27,8 +27,14 @@ class StylesRenderer: self._cache: dict[int, list[Segment]] = {} self._dirty_lines: set[int] = set() - def invalidate(self, region: Region) -> None: - self._dirty_lines.update(region.y_range) + def set_dirty(self, *regions: Region) -> None: + if regions: + for region in regions: + self._dirty_lines.update(region.y_range) + else: + self._dirty_lines.clear() + for y in self._widget.size.lines: + self._dirty_lines.add(y) def render(self, region: Region) -> Lines: @@ -50,12 +56,14 @@ class StylesRenderer: 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.y_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] @@ -64,13 +72,13 @@ class StylesRenderer: if region.x_extents != (0, width): _line_crop = line_crop - x1, x2 = region.x_range + x1, x2 = region.x_extents lines = [_line_crop(line, x1, x2, width) for line in lines] return lines - def render_content_line(self, y: int, width: int) -> list[Segment]: - return [Segment((str(y) * width)[:width])] + def render_content_line(self, y: int) -> list[Segment]: + return self._widget.render_line(y) def render_line( self, @@ -149,11 +157,9 @@ class StylesRenderer: return [Segment(" " * width, background_style)] # Apply background style - line = list( - Segment.apply_style( - self.render_content_line(y - gutter.top, content_width), inner_style - ) - ) + line = self.render_content_line(y - gutter.top) + if inner_style: + line = list(Segment.apply_style(line, inner_style)) # Add padding if pad_left and pad_right: @@ -184,7 +190,7 @@ class StylesRenderer: from_color(border_left_color.rich_color), ) _, (_, _, right), _ = get_box( - border_left, + border_right, inner_style, outer_style, from_color(border_right_color.rich_color), @@ -195,7 +201,7 @@ class StylesRenderer: elif border_left: return [left, *line] - return [right, *line] + return [*line, right] if __name__ == "__main__": diff --git a/src/textual/geometry.py b/src/textual/geometry.py index a3d611a15..79ca4dd27 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -139,6 +139,10 @@ class Size(NamedTuple): width, height = self return Region(0, 0, width, height) + @property + def lines(self) -> list[int]: + return list(range(self.height)) + def __add__(self, other: object) -> Size: if isinstance(other, tuple): width, height = self diff --git a/src/textual/widget.py b/src/textual/widget.py index fefe087c8..e9d44ffaf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -17,6 +17,7 @@ from rich.align import Align from rich.console import Console, RenderableType from rich.measure import Measurement from rich.padding import Padding +from rich.segment import Segment from rich.style import Style from . import errors @@ -25,6 +26,7 @@ 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 ._types import Lines from .dom import DOMNode from ._layout import ArrangeResult @@ -118,6 +120,8 @@ class Widget(DOMNode): self._arrangement: ArrangeResult | None = None self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) + self._styles_renderer = StylesRenderer(self) + super().__init__(name=name, id=id, classes=classes) self.add_children(*children) @@ -833,9 +837,18 @@ class Widget(DOMNode): """ renderable = self.render() - renderable = self._style_renderable(renderable) + styles = self.styles + content_align = (styles.content_align_horizontal, styles.content_align_vertical) + if content_align != ("left", "top"): + horizontal, vertical = content_align + renderable = Align(renderable, horizontal, vertical=vertical) + return renderable + @property + def content_size(self) -> Size: + return self._size - self.styles.gutter.totals + @property def size(self) -> Size: return self._size @@ -955,13 +968,13 @@ class Widget(DOMNode): def _render_lines(self) -> None: """Render all lines.""" - width, height = self.size + width, height = self.content_size renderable = self.render_styled() options = self.console.options.update_dimensions(width, height).update( highlight=False ) - lines = self.console.render_lines(renderable, options) - self._render_cache = RenderCache(self.size, lines) + lines = self.console.render_lines(renderable, options, style=self.rich_style) + self._render_cache = RenderCache(self.content_size, lines) self._dirty_regions.clear() def _crop_lines(self, lines: Lines, x1, x2) -> Lines: @@ -971,6 +984,10 @@ class Widget(DOMNode): lines = [_line_crop(line, x1, x2, width) for line in lines] return lines + def render_line(self, y) -> list[Segment]: + line = self._render_cache.lines[y] + return line + def render_lines(self, crop: Region) -> Lines: """Render the widget in to lines. @@ -981,8 +998,12 @@ 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) From 81481a0e16c41cfd5c7dd2e6c94e16956f0e2421 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 30 Jun 2022 21:41:37 +0100 Subject: [PATCH 06/18] Added tint and opacity --- sandbox/will/basic.css | 6 +- src/textual/_compositor.py | 4 +- src/textual/_styles_render.py | 62 +++++++++++++-------- src/textual/css/styles.py | 1 + src/textual/geometry.py | 22 ++++---- src/textual/renderables/opacity.py | 89 +++++++++++++++++------------- src/textual/renderables/tint.py | 31 ++++++++--- tests/test_geometry.py | 12 ++-- 8 files changed, 136 insertions(+), 91 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 791d3ee14..579ec30c3 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,9 +15,9 @@ scrollbar-size-vertical: 2; } -/* *:hover { - tint: red 30%; -} */ +*:hover { + /* tint: 30% red; */ +} App > Screen { layout: dock; diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bb2523485..f4eb19095 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -657,13 +657,13 @@ class Compositor: for region, clip, lines in renders: render_region = intersection(region, clip) - for y, line in zip(render_region.y_range, lines): + for y, line in zip(render_region.line_range, lines): if not is_rendered_line(y): continue chops_line = chops[y] - first_cut, last_cut = render_region.x_extents + first_cut, last_cut = render_region.column_span cuts_line = cuts[y] final_cuts = [ cut for cut in cuts_line if (last_cut >= cut >= first_cut) diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index bdc437b76..ca4fbcaad 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -1,13 +1,15 @@ from __future__ import annotations from rich.style import Style -from typing import TYPE_CHECKING +from typing import Iterable, TYPE_CHECKING from rich.segment import Segment from ._border import get_box 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 from ._types import Lines from .geometry import Region, Size @@ -22,19 +24,21 @@ NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""} 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.y_range) + self._dirty_lines.update(region.line_range) else: self._dirty_lines.clear() - for y in self._widget.size.lines: - self._dirty_lines.add(y) + self._dirty_lines.update(self._widget.size.lines_range) def render(self, region: Region) -> Lines: @@ -60,7 +64,7 @@ class StylesRenderer: is_dirty = self._dirty_lines.__contains__ render_line = self.render_line - for y in region.y_range: + 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)) @@ -68,11 +72,11 @@ class StylesRenderer: else: line = self._cache[y] add_line(line) - self._dirty_lines.difference_update(region.y_range) + self._dirty_lines.difference_update(region.line_range) - if region.x_extents != (0, width): + if region.column_span != (0, width): _line_crop = line_crop - x1, x2 = region.x_extents + x1, x2 = region.column_span lines = [_line_crop(line, x1, x2, width) for line in lines] return lines @@ -109,9 +113,17 @@ class StylesRenderer: from_color = Style.from_color - inner_style = from_color(bgcolor=background.rich_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) + # Draw top or bottom borders if border_top and y in (0, height - 1): @@ -120,22 +132,24 @@ class StylesRenderer: border_top if y == 0 else border_bottom, inner_style, outer_style, - Style.from_color(color=border_color.rich_color), + from_color(color=border_color.rich_color), ) box1, box2, box3 = box_segments[0 if y == 0 else 2] if border_left and border_right: - return [box1, Segment(box2.text * (width - 2), box2.style), box3] + return post([box1, Segment(box2.text * (width - 2), box2.style), box3]) elif border_left: - return [box1, Segment(box2.text * (width - 1), box2.style)] + return post([box1, Segment(box2.text * (width - 1), box2.style)]) elif border_right: - return [Segment(box2.text * (width - 1), box2.style), box3] + return post([Segment(box2.text * (width - 1), box2.style), box3]) else: - return [Segment(box2.text * width, box2.style)] + return post([Segment(box2.text * width, box2.style)]) # Draw padding if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): - background_style = from_color(bgcolor=background.rich_color) + background_style = from_color( + color=rich_style.color, bgcolor=background.rich_color + ) _, (left, _, _), _ = get_box( border_left, inner_style, @@ -146,15 +160,15 @@ class StylesRenderer: border_right, inner_style, outer_style, - Style.from_color(color=border_right_color.rich_color), + from_color(color=border_right_color.rich_color), ) if border_left and border_right: - return [left, Segment(" " * (width - 2), background_style), right] + return post([left, Segment(" " * (width - 2), background_style), right]) if border_left: - return [left, Segment(" " * (width - 1), background_style)] + return post([left, Segment(" " * (width - 1), background_style)]) if border_right: - return [Segment(" " * (width - 1), background_style), right] - return [Segment(" " * width, background_style)] + return post([Segment(" " * (width - 1), background_style), right]) + return post([Segment(" " * width, background_style)]) # Apply background style line = self.render_content_line(y - gutter.top) @@ -180,7 +194,7 @@ class StylesRenderer: ] if not border_left and not border_right: - return line + return post(line) # Add left / right border _, (left, _, _), _ = get_box( @@ -197,11 +211,11 @@ class StylesRenderer: ) if border_left and border_right: - return [left, *line, right] + return post([left, *line, right]) elif border_left: - return [left, *line] + return post([left, *line]) - return [*line, right] + return post([*line, right]) if __name__ == "__main__": diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 30a2f2234..34543e5b0 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -167,6 +167,7 @@ class StylesBase(ABC): "max_height", "color", "background", + "opacity", "tint", "scrollbar_color", "scrollbar_color_hover", diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 79ca4dd27..09454411e 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -140,8 +140,8 @@ class Size(NamedTuple): return Region(0, 0, width, height) @property - def lines(self) -> list[int]: - return list(range(self.height)) + def lines_range(self) -> range: + return range(self.height) def __add__(self, other: object) -> Size: if isinstance(other, tuple): @@ -310,21 +310,21 @@ class Region(NamedTuple): return bool(self.width and self.height) @property - def x_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + def column_span(self) -> tuple[int, int]: + """Get the start and end column (x coord). - The end value is non inclusive. + The end value is exclusive. Returns: - tuple[int, int]: Pair of x coordinates (row numbers). + tuple[int, int]: Pair of x coordinates (column numbers). """ return (self.x, self.x + self.width) @property - def y_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + def line_span(self) -> tuple[int, int]: + """Get the star and end line number (y coord). - The end value is non inclusive. + The end value is exclusive. Returns: tuple[int, int]: Pair of y coordinates (line numbers). @@ -385,12 +385,12 @@ class Region(NamedTuple): return x, y, x + width, y + height @property - def x_range(self) -> range: + def column_range(self) -> range: """A range object for X coordinates.""" return range(self.x, self.x + self.width) @property - def y_range(self) -> range: + def line_range(self) -> range: """A range object for Y coordinates.""" return range(self.y, self.y + self.height) diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py index f1d73d335..5dabac89a 100644 --- a/src/textual/renderables/opacity.py +++ b/src/textual/renderables/opacity.py @@ -1,4 +1,5 @@ import functools +from typing import Iterable from rich.color import Color from rich.console import ConsoleOptions, Console, RenderResult, RenderableType @@ -8,46 +9,9 @@ from rich.style import Style from textual.renderables._blend_colors import blend_colors -class Opacity: - """Wrap a renderable to blend foreground color into the background color. - - Args: - renderable (RenderableType): The RenderableType to manipulate. - opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. - """ - - def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: - self.renderable = renderable - self.opacity = opacity - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - segments = console.render(self.renderable, options) - opacity = self.opacity - for segment in segments: - style = segment.style - if not style: - yield segment - continue - fg = style.color - bg = style.bgcolor - if fg and fg.triplet and bg and bg.triplet: - color_style = _get_blended_style_cached( - fg_color=fg, bg_color=bg, opacity=opacity - ) - yield Segment( - segment.text, - style + color_style, - segment.control, - ) - else: - yield segment - - @functools.lru_cache(maxsize=1024) def _get_blended_style_cached( - fg_color: Color, bg_color: Color, opacity: float + bg_color: Color, fg_color: Color, opacity: float ) -> Style: return Style.from_color( color=blend_colors(bg_color, fg_color, ratio=opacity), @@ -55,6 +19,55 @@ def _get_blended_style_cached( ) +class Opacity: + """Blend foreground in to background.""" + + def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: + """Wrap a renderable to blend foreground color into the background color. + + Args: + renderable (RenderableType): The RenderableType to manipulate. + opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. + """ + self.renderable = renderable + self.opacity = opacity + + @classmethod + def process_segments( + cls, segments: Iterable[Segment], opacity: float + ) -> Iterable[Segment]: + """Apply opacity to segments. + + Args: + segments (Iterable[Segment]): Incoming segments. + opacity (float): Opacity to apply. + + Returns: + Iterable[Segment]: Segments with applied opacity. + + """ + _Segment = Segment + for segment in segments: + text, style, control = segment + if not style: + yield segment + continue + + color = style.color + bgcolor = style.bgcolor + if color and color.triplet and bgcolor and bgcolor.triplet: + color_style = _get_blended_style_cached(bgcolor, color, opacity) + yield _Segment(text, style + color_style) + else: + yield segment + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + return self.process_segments(segments, self.opacity) + + if __name__ == "__main__": from rich.live import Live from rich.panel import Panel diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index 464bdd76d..196a07be4 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Iterable from rich.console import ConsoleOptions, Console, RenderResult, RenderableType from rich.segment import Segment @@ -12,7 +13,7 @@ class Tint: """Applies a color on top of an existing renderable.""" def __init__(self, renderable: RenderableType, color: Color) -> None: - """_summary_ + """Wrap a renderable to apply a tint color. Args: renderable (RenderableType): A renderable. @@ -21,20 +22,29 @@ class Tint: self.renderable = renderable self.color = color - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - segments = console.render(self.renderable, options) + @classmethod + def process_segments( + cls, segments: Iterable[Segment], color: Color + ) -> Iterable[Segment]: + """Apply tint to segments. - color = self.color + Args: + segments (Iterable[Segment]): Incoming segments. + color (Color): Color of tint. + + Returns: + Iterable[Segment]: Segments with applied tint. + + """ from_rich_color = Color.from_rich_color style_from_color = Style.from_color + _Segment = Segment for segment in segments: text, style, control = segment if control or style is None: yield segment else: - yield Segment( + yield _Segment( text, ( style @@ -45,3 +55,10 @@ class Tint: ), control, ) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + color = self.color + return self.process_segments(segments, color) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 241acdd34..eb9e9a16a 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -279,11 +279,11 @@ def test_size_sub(): def test_region_x_extents(): - assert Region(5, 10, 20, 30).x_extents == (5, 25) + assert Region(5, 10, 20, 30).column_span == (5, 25) def test_region_y_extents(): - assert Region(5, 10, 20, 30).y_extents == (10, 40) + assert Region(5, 10, 20, 30).line_span == (10, 40) def test_region_x_max(): @@ -294,12 +294,12 @@ def test_region_y_max(): assert Region(5, 10, 20, 30).bottom == 40 -def test_region_x_range(): - assert Region(5, 10, 20, 30).x_range == range(5, 25) +def test_region_columns_range(): + assert Region(5, 10, 20, 30).column_range == range(5, 25) -def test_region_y_range(): - assert Region(5, 10, 20, 30).y_range == range(10, 40) +def test_region_lines_range(): + assert Region(5, 10, 20, 30).line_range == range(10, 40) def test_region_reset_offset(): From 97c58a7b0ae9781ae882bfa4194c5c1f2fff8735 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 2 Jul 2022 14:37:00 +0100 Subject: [PATCH 07/18] implemented outline --- sandbox/will/basic.css | 9 +- sandbox/will/basic.py | 2 +- src/textual/_border.py | 34 ++++-- src/textual/_segment_tools.py | 36 +++++++ src/textual/_styles_render.py | 154 +++++++++++++++------------ src/textual/css/_style_properties.py | 2 + 6 files changed, 156 insertions(+), 81 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 579ec30c3..95b8a3cd6 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -16,7 +16,8 @@ } *:hover { - /* tint: 30% red; */ + tint: 30% red; + /* outline: heavy red; */ } App > Screen { @@ -224,13 +225,15 @@ Warning { Success { width: 100%; - height:3; + width:90%; + height:auto; box-sizing: border-box; background: $success-lighten-3; color: $text-success-lighten-3-fade-1; + border-top: hkey $success; border-bottom: hkey $success; - margin: 1 2; + text-style: bold; align-horizontal: center; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index a1a9fc2bb..3cb288db1 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -88,7 +88,7 @@ class Warning(Widget): class Success(Widget): def render(self) -> Text: - return Text("This is a success message", justify="center") + return Text("This\nis\na\nsuccess\n message", justify="center") class BasicApp(App, css_path="basic.css"): diff --git a/src/textual/_border.py b/src/textual/_border.py index 576742413..a87a7bd5a 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -70,15 +70,19 @@ INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidde BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]] +BoxSegments: TypeAlias = tuple[ + tuple[Segment, Segment, Segment], + tuple[Segment, Segment, Segment], + tuple[Segment, Segment, Segment], +] + +Borders: TypeAlias = tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle] + @lru_cache(maxsize=1024) def get_box( name: EdgeType, inner_style: Style, outer_style: Style, style: Style -) -> tuple[ - tuple[Segment, Segment, Segment], - tuple[Segment, Segment, Segment], - tuple[Segment, Segment, Segment], -]: +) -> BoxSegments: """Get segments used to render a box. Args: @@ -124,6 +128,20 @@ def get_box( ) +def render_row( + box_row: tuple[Segment, Segment, Segment], width: int, left: bool, right: bool +) -> list[Segment]: + box1, box2, box3 = box_row + if left and right: + return [box1, Segment(box2.text * (width - 2), box2.style), box3] + if left: + return [box1, Segment(box2.text * (width - 1), box2.style)] + if right: + return [Segment(box2.text * (width - 1), box2.style), box3] + else: + return [Segment(box2.text * width, box2.style)] + + @rich.repr.auto class Border: """Renders Textual CSS borders. @@ -137,13 +155,13 @@ class Border: def __init__( self, renderable: RenderableType, - edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle], + borders: Borders, inner_color: Color, outer_color: Color, outline: bool = False, ): self.renderable = renderable - self.edge_styles = edge_styles + self.edge_styles = borders self.outline = outline ( @@ -151,7 +169,7 @@ class Border: (right, right_color), (bottom, bottom_color), (left, left_color), - ) = edge_styles + ) = borders self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] self._sides = (top, right, bottom, left) from_color = Style.from_color diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index a9bccb699..5cbcf9c8d 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -4,7 +4,10 @@ Tools for processing Segments, or lists of Segments. from __future__ import annotations +from typing import Iterable + from rich.segment import Segment +from rich.style import Style from ._cells import cell_len @@ -88,3 +91,36 @@ def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]: else: segments.pop() return segments + + +def line_pad( + segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style +) -> Iterable[Segment]: + """Adds padding to the left and / or right of a list of segments. + + Args: + segments (list[Segment]): A line (list of Segments). + pad_left (int): Cells to pad on the left. + pad_right (int): Cells to pad on the right. + style (Style): Style of padded cells. + + Returns: + list[Segment]: A new line with padding. + """ + if pad_left and pad_right: + segments = [ + Segment(" " * pad_left, style), + *segments, + Segment(" " * pad_right, style), + ] + elif pad_left: + segments = [ + Segment(" " * pad_left, style), + *segments, + ] + elif pad_right: + segments = [ + *segments, + Segment(" " * pad_right, style), + ] + return segments diff --git a/src/textual/_styles_render.py b/src/textual/_styles_render.py index ca4fbcaad..fb7fed1f0 100644 --- a/src/textual/_styles_render.py +++ b/src/textual/_styles_render.py @@ -5,12 +5,12 @@ from typing import Iterable, TYPE_CHECKING from rich.segment import Segment -from ._border import get_box +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 +from ._segment_tools import line_crop, line_pad, line_trim from ._types import Lines from .geometry import Region, Size @@ -20,9 +20,6 @@ if TYPE_CHECKING: from .widget import Widget -NORMALIZE_BORDER: dict[EdgeType, EdgeType] = {"none": "", "hidden": ""} - - class StylesRenderer: """Responsible for rendering CSS Styles and keeping a cached of rendered lines.""" @@ -95,7 +92,6 @@ class StylesRenderer: gutter = styles.gutter width, height = size - content_width, content_height = size - gutter.totals pad_top, pad_right, pad_bottom, pad_left = styles.padding ( @@ -105,11 +101,12 @@ class StylesRenderer: (border_left, border_left_color), ) = styles.border - normalize_border_get = NORMALIZE_BORDER.get - border_top = normalize_border_get(border_top, border_top) - border_right = normalize_border_get(border_right, border_right) - border_bottom = normalize_border_get(border_bottom, border_bottom) - border_left = normalize_border_get(border_left, border_left) + ( + (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 @@ -124,8 +121,9 @@ class StylesRenderer: 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 in (0, height - 1): + 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( @@ -134,19 +132,17 @@ class StylesRenderer: outer_style, from_color(color=border_color.rich_color), ) - box1, box2, box3 = box_segments[0 if y == 0 else 2] - - if border_left and border_right: - return post([box1, Segment(box2.text * (width - 2), box2.style), box3]) - elif border_left: - return post([box1, Segment(box2.text * (width - 1), box2.style)]) - elif border_right: - return post([Segment(box2.text * (width - 1), box2.style), box3]) - else: - return post([Segment(box2.text * width, box2.style)]) + line = render_row( + box_segments[0 if y == 0 else 2], + width, + border_left != "", + border_right != "", + ) # Draw padding - if (pad_top and y < gutter.top) or (pad_bottom and y >= height - gutter.bottom): + 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 ) @@ -163,59 +159,79 @@ class StylesRenderer: from_color(color=border_right_color.rich_color), ) if border_left and border_right: - return post([left, Segment(" " * (width - 2), background_style), right]) + line = [left, Segment(" " * (width - 2), background_style), right] if border_left: - return post([left, Segment(" " * (width - 1), background_style)]) + line = [left, Segment(" " * (width - 1), background_style)] if border_right: - return post([Segment(" " * (width - 1), background_style), right]) - return post([Segment(" " * width, background_style)]) + 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) - # Apply background style - line = self.render_content_line(y - gutter.top) - if inner_style: - line = list(Segment.apply_style(line, 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), + ) - # Add padding - if pad_left and pad_right: - line = [ - Segment(" " * pad_left, inner_style), - *line, - Segment(" " * pad_right, inner_style), - ] - elif pad_left: - line = [ - Segment(" " * pad_left, inner_style), - *line, - ] - elif pad_right: - line = [ - *line, - Segment(" " * pad_right, inner_style), - ] + if border_left and border_right: + line = [left, *line, right] + elif border_left: + line = [left, *line] + else: + line = [*line, right] - if not border_left and not border_right: - return post(line) + 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 != "", + ) - # 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), - ) + 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] - if border_left and border_right: - return post([left, *line, right]) - elif border_left: - return post([left, *line]) - - return post([*line, right]) + return post(line) if __name__ == "__main__": diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 99bf6f50c..e3ffd4141 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -221,6 +221,8 @@ class BoxProperty: it's style. Example types are "rounded", "solid", and "dashed". """ box_type, color = obj.get_rule(self.name) or ("", self._default_color) + if box_type in {"none", "hidden"}: + box_type = "" return (box_type, color) def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None): From f3a7c9483f64b4e7b20c58f8433fcd910cf67f31 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 14:48:28 +0100 Subject: [PATCH 08/18] apply style to widget --- sandbox/will/basic.css | 2 +- sandbox/will/basic.py | 2 +- src/textual/_border.py | 11 ++ src/textual/_segment_tools.py | 4 +- src/textual/_styles_cache.py | 305 +++++++++++++++++++++++++++++ src/textual/_styles_render.py | 260 ------------------------ src/textual/color.py | 2 +- src/textual/css/constants.py | 1 + src/textual/css/styles.py | 6 - src/textual/geometry.py | 3 +- src/textual/widget.py | 50 +++-- src/textual/widgets/_data_table.py | 71 ++++--- tests/test_border.py | 25 +++ tests/test_segment_tools.py | 24 ++- tests/test_styles_cache.py | 260 ++++++++++++++++++++++++ 15 files changed, 701 insertions(+), 325 deletions(-) create mode 100644 src/textual/_styles_cache.py delete mode 100644 src/textual/_styles_render.py create mode 100644 tests/test_border.py create mode 100644 tests/test_styles_cache.py 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 From 2ac95c5922d28c8c74d15bd3b7f2bbb541a1c669 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 20:37:59 +0100 Subject: [PATCH 09/18] render lines and scrollbars --- sandbox/will/basic.css | 13 +- src/textual/_compositor.py | 28 ++-- src/textual/_styles_cache.py | 90 ++++++++----- src/textual/geometry.py | 5 + src/textual/widget.py | 202 +++++++++++++++-------------- src/textual/widgets/_data_table.py | 33 ----- tests/test_styles_cache.py | 8 ++ 7 files changed, 193 insertions(+), 186 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 8b397322a..0624e0846 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -28,9 +28,11 @@ App > Screen { } DataTable { - border: solid red; - - margin: 1 1; + /*border:heavy red;*/ + /* tint: 10% green; */ + /* opacity: 50%; */ + padding: 1; + margin: 1 2; height: 12; } @@ -105,7 +107,7 @@ Tweet { .scrollable { - + overflow-y: scroll; margin: 1 2; height: 20; @@ -113,8 +115,7 @@ Tweet { layout: vertical; } -.code { - +.code { height: auto; } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f4eb19095..d1a93576b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -386,33 +386,23 @@ class Compositor: # Add any scrollbars for chrome_widget, chrome_region in widget._arrange_scrollbars( - container_size + container_region ): map[chrome_widget] = MapGeometry( - chrome_region + container_region.offset + layout_offset, + chrome_region + layout_offset, order, clip, container_size, container_size, ) - if widget.is_container: - # Add the container widget, which will render a background - map[widget] = MapGeometry( - region + layout_offset, - order, - clip, - total_region.size, - container_size, - ) - else: - map[widget] = MapGeometry( - child_region + layout_offset, - order, - clip, - child_region.size, - container_size, - ) + map[widget] = MapGeometry( + region + layout_offset, + order, + clip, + total_region.size, + container_size, + ) else: # Add the widget to the map diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index da6b6b235..2047985e4 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -10,7 +10,7 @@ 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 .geometry import Spacing, Region, Size from .renderables.opacity import Opacity from .renderables.tint import Tint @@ -62,9 +62,18 @@ class StylesCache: self.clear() def is_dirty(self, y: int) -> bool: + """Check if a given line is dirty (needs to be rendered again). + + Args: + y (int): Y coordinate of line. + + Returns: + bool: True if line requires a render, False if can be cached. + """ return y in self._dirty_lines def clear(self) -> None: + """Clear the styles cache (will cause the content to re-render).""" self._cache.clear() self._dirty_lines.clear() @@ -79,12 +88,15 @@ class StylesCache: Lines: Rendered lines. """ (base_background, base_color), (background, color) = widget.colors + padding = widget.styles.padding + widget.scrollbar_gutter lines = self.render( widget.styles, widget.region.size, base_background, background, widget.render_line, + content_size=widget.content_region.size, + padding=padding, crop=crop, ) return lines @@ -95,35 +107,33 @@ class StylesCache: size: Size, base_background: Color, background: Color, - render_line: RenderLineCallback, + render_content_line: RenderLineCallback, + content_size: Size | None = None, + padding: Spacing | None = None, crop: Region | None = None, ) -> Lines: - """Render a given region. + """Render a widget content plus CSS styles. Args: - region (Region): A region in the screen to render. + styles (StylesBase): CSS Styles object. + size (Size): Size of widget. + base_background (Color): Background color beneath widget. + background (Color): Background color of widget. + render_content_line (RenderLineCallback): Callback to render content line. + content_size (Size | None, optional): Size of content or None to assume full size. Defaults to None. + padding (Spacing | None, optional): Override padding from Styles, or None to use styles.padding. Defaults to None. + crop (Region | None, optional): Region to crop to. Defaults to None. Returns: - Lines: List of Segments, one per line. + Lines: Rendered lines. """ - return self._render( - size, - size.region if crop is None else crop, - styles, - base_background, - background, - render_line, - ) + if content_size is None: + content_size = size + if padding is None: + padding = styles.padding + if crop is None: + crop = size.region - 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() @@ -137,7 +147,14 @@ class StylesCache: 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 + styles, + y, + size, + content_size, + padding, + base_background, + background, + render_content_line, ) line = list(simplify(line)) self._cache[y] = line @@ -158,27 +175,34 @@ class StylesCache: styles: StylesBase, y: int, size: Size, + content_size: Size, + padding: Spacing, base_background: Color, background: Color, render_content_line: RenderLineCallback, ) -> list[Segment]: - """Render a styled lines. + """Render a styled line. Args: - styles (RenderStyles): Styles object. - y (int): The y coordinate of the line (relative to top of widget) + styles (StylesBase): Styles object. + y (int): The y coordinate of the line (relative to widget screen offset). size (Size): Size of the widget. - base_background (Color): The background color beneath this widget. - background (Color): Background color of the widget. + content_size (Size): Size of the content area. + padding (Spacing): Padding. + base_background (Color): Background color of widget beneath this line. + background (Color): Background color of widget. + render_content_line (RenderLineCallback): Callback to render a line of content. Returns: - list[Segment]: A line as a list of segments. + list[Segment]: _description_ """ gutter = styles.gutter width, height = size + content_width, content_height = content_size + + pad_top, pad_right, pad_bottom, pad_left = padding - pad_top, pad_right, pad_bottom, pad_left = styles.padding ( (border_top, border_top_color), (border_right, border_right_color), @@ -252,7 +276,11 @@ class StylesCache: line = [Segment(" " * width, background_style)] else: # Content with border and padding (C) - line = render_content_line(y - gutter.top) + content_y = y - gutter.top + if content_y < content_height: + line = render_content_line(y - gutter.top) + else: + line = [Segment(" " * content_width, inner)] if inner: line = Segment.apply_style(line, inner) line = line_pad(line, pad_left, pad_right, inner) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 4bca0da80..826aa4b15 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -459,6 +459,11 @@ class Region(NamedTuple): height + expand_height * 2, ) + def enlarge(self, size: tuple[int, int]) -> Region: + add_width, add_height = size + x, y, width, height = self + return Region(x, y, width + add_width, height + add_height) + @lru_cache(maxsize=1024) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. diff --git a/src/textual/widget.py b/src/textual/widget.py index eea289c6c..fca6e3aec 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -405,11 +405,6 @@ class Widget(DOMNode): enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar return enabled - @property - def scrollbar_dimensions(self) -> tuple[int, int]: - """Get the size of any scrollbars on the widget""" - return (self.scrollbar_size_horizontal, self.scrollbar_size_vertical) - @property def scrollbar_size_vertical(self) -> int: """Get the width used by the *vertical* scrollbar.""" @@ -426,6 +421,108 @@ class Widget(DOMNode): else 0 ) + @property + def scrollbar_gutter(self) -> Spacing: + gutter = Spacing( + 0, self.scrollbar_size_vertical, self.scrollbar_size_horizontal, 0 + ) + return gutter + + @property + def gutter(self) -> Spacing: + """Spacing for padding / border / scrollbars.""" + return self.styles.gutter + self.scrollbar_gutter + + @property + def content_size(self) -> Size: + return self.content_region.size + + @property + def size(self) -> Size: + return self._size + + @property + def container_size(self) -> Size: + return self._container_size + + @property + def content_region(self) -> Region: + """Gets an absolute region containing the content (minus padding and border).""" + content_region = self.region.shrink(self.gutter) + return content_region + + @property + def content_offset(self) -> Offset: + """An offset from the Widget origin where the content begins.""" + x, y = self.gutter.top_left + return Offset(x, y) + + @property + def region(self) -> Region: + """The region occupied by this widget, relative to the Screen.""" + try: + return self.screen.find_widget(self).region + except errors.NoWidget: + return Region() + + @property + def window_region(self) -> Region: + """The region within the scrollable area that is currently visible. + + Returns: + Region: New region. + """ + window_region = self.region.at_offset(self.scroll_offset) + return window_region + + @property + def scroll_offset(self) -> Offset: + return Offset(int(self.scroll_x), int(self.scroll_y)) + + @property + def is_transparent(self) -> bool: + """Check if the background styles is not set. + + Returns: + bool: ``True`` if there is background color, otherwise ``False``. + """ + return self.is_scrollable and self.styles.background.is_transparent + + @property + def console(self) -> Console: + """Get the current console.""" + return active_app.get().console + + @property + def animate(self) -> BoundAnimator: + if self._animate is None: + self._animate = self.app.animator.bind(self) + assert self._animate is not None + return self._animate + + @property + def layout(self) -> Layout: + """Get the layout object if set in styles, or a default layout.""" + return self.styles.layout or self._default_layout + + @property + def is_container(self) -> bool: + """Check if this widget is a container (contains other widgets). + + Returns: + bool: True if this widget is a container. + """ + return self.styles.layout is not None or bool(self.children) + + @property + def is_scrollable(self) -> bool: + """Check if this Widget may be scrolled. + + Returns: + bool: True if this widget may be scrolled. + """ + return self.is_container + def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). @@ -731,17 +828,17 @@ class Widget(DOMNode): region, _ = region.split_horizontal(-scrollbar_size_horizontal) return region - def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, Region]]: + def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]]: """Arrange the 'chrome' widgets (typically scrollbars) for a layout element. Args: - size (Size): Size of the containing region. + region (Region): The containing region. Returns: Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region. """ - region = size.region + show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled scrollbar_size_horizontal = self.scrollbar_size_horizontal @@ -845,95 +942,6 @@ class Widget(DOMNode): return renderable - @property - def content_size(self) -> Size: - return self.content_region.size - - @property - def size(self) -> Size: - return self._size - - @property - def container_size(self) -> Size: - return self._container_size - - @property - def content_region(self) -> Region: - """Gets an absolute region containing the content (minus padding and border).""" - 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.gutter.top_left - return Offset(x, y) - - @property - def region(self) -> Region: - """The region occupied by this widget, relative to the Screen.""" - try: - return self.screen.find_widget(self).region - except errors.NoWidget: - return Region() - - @property - def window_region(self) -> Region: - """The region within the scrollable area that is currently visible. - - Returns: - Region: New region. - """ - window_region = self.region.at_offset(self.scroll_offset) - return window_region - - @property - def scroll_offset(self) -> Offset: - return Offset(int(self.scroll_x), int(self.scroll_y)) - - @property - def is_transparent(self) -> bool: - """Check if the background styles is not set. - - Returns: - bool: ``True`` if there is background color, otherwise ``False``. - """ - return self.is_scrollable and self.styles.background.is_transparent - - @property - def console(self) -> Console: - """Get the current console.""" - return active_app.get().console - - @property - def animate(self) -> BoundAnimator: - if self._animate is None: - self._animate = self.app.animator.bind(self) - assert self._animate is not None - return self._animate - - @property - def layout(self) -> Layout: - """Get the layout object if set in styles, or a default layout.""" - return self.styles.layout or self._default_layout - - @property - def is_container(self) -> bool: - """Check if this widget is a container (contains other widgets). - - Returns: - bool: True if this widget is a container. - """ - return self.styles.layout is not None or bool(self.children) - - @property - def is_scrollable(self) -> bool: - """Check if this Widget may be scrolled. - - Returns: - bool: True if this widget may be scrolled. - """ - return self.is_container - def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" self.app.update_styles() diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 5cf4cc068..94d86cbec 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -518,38 +518,6 @@ class DataTable(ScrollView, Generic[CellType]): 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: @@ -573,7 +541,6 @@ 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_styles_cache.py b/tests/test_styles_cache.py index b927c6429..5f3ed09a4 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -37,6 +37,7 @@ def test_no_styles(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), ) expected = [ [Segment("foo", styles.rich_style)], @@ -61,6 +62,7 @@ def test_border(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), ) text_content = _extract_content(lines) @@ -91,6 +93,7 @@ def test_padding(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), ) text_content = _extract_content(lines) @@ -122,6 +125,7 @@ def test_padding_border(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), ) text_content = _extract_content(lines) @@ -154,6 +158,7 @@ def test_outline(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), ) text_content = _extract_content(lines) @@ -181,6 +186,7 @@ def test_crop(): Color.parse("blue"), Color.parse("green"), content.__getitem__, + content_size=Size(3, 3), crop=Region(2, 2, 3, 3), ) text_content = _extract_content(lines) @@ -239,6 +245,7 @@ def test_dirty_cache(): Color.parse("blue"), Color.parse("green"), get_content_line, + content_size=Size(3, 3), ) assert rendered_lines == [] del rendered_lines[:] @@ -254,6 +261,7 @@ def test_dirty_cache(): Color.parse("blue"), Color.parse("green"), get_content_line, + content_size=Size(3, 3), ) assert rendered_lines == [0, 1] text_content = _extract_content(lines) From 8b1babb179622a436356da0c8f11110ec0185736 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 20:46:36 +0100 Subject: [PATCH 10/18] typing fix --- src/textual/_border.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index ab3e1ba96..99dcc0665 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -70,13 +70,13 @@ INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidde BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]] -BoxSegments: TypeAlias = tuple[ - tuple[Segment, Segment, Segment], - tuple[Segment, Segment, Segment], - tuple[Segment, Segment, Segment], +BoxSegments: TypeAlias = Tuple[ + Tuple[Segment, Segment, Segment], + Tuple[Segment, Segment, Segment], + Tuple[Segment, Segment, Segment], ] -Borders: TypeAlias = tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle] +Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle] @lru_cache(maxsize=1024) From 12ce623ddf8dffacefea8372837d2866dc150149 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 20:47:47 +0100 Subject: [PATCH 11/18] typing fix --- src/textual/_styles_cache.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 2047985e4..7d661e169 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Callable, Iterable, List from rich.segment import Segment from rich.style import Style @@ -10,7 +10,7 @@ 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 Spacing, Region, Size +from .geometry import Region, Size, Spacing from .renderables.opacity import Opacity from .renderables.tint import Tint @@ -25,7 +25,7 @@ if TYPE_CHECKING: from .widget import Widget -RenderLineCallback: TypeAlias = Callable[[int], list[Segment]] +RenderLineCallback: TypeAlias = Callable[[int], List[Segment]] class StylesCache: From 415db09a4b6e6efb5d9a8244d973854905910dbb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 20:53:53 +0100 Subject: [PATCH 12/18] docstrings --- src/textual/widget.py | 22 +++++++++++----------- src/textual/widgets/_data_table.py | 16 ++++++++++++++++ tests/test_styles_cache.py | 2 ++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index fca6e3aec..753f74821 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -974,7 +974,7 @@ class Widget(DOMNode): else: self.refresh() - def _render_lines(self) -> None: + def _render_content(self) -> None: """Render all lines.""" width, height = self.content_size renderable = self.render_styled() @@ -985,14 +985,17 @@ class Widget(DOMNode): self._render_cache = RenderCache(self.content_size, lines) self._dirty_regions.clear() - def _crop_lines(self, lines: Lines, x1, x2) -> Lines: - width = self.size.width - if (x1, x2) != (0, width): - _line_crop = line_crop - lines = [_line_crop(line, x1, x2, width) for line in lines] - return lines + def render_line(self, y: int) -> list[Segment]: + """Render a line of content. - def render_line(self, y) -> list[Segment]: + Args: + y (int): Y Coordinate of line. + + Returns: + list[Segment]: A rendered line. + """ + if self._dirty_regions: + self._render_content() line = self._render_cache.lines[y] return line @@ -1005,9 +1008,6 @@ class Widget(DOMNode): Returns: Lines: A list of list of segments """ - if self._dirty_regions: - self._render_lines() - lines = self._styles_cache.render_widget(self, crop) return lines diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 94d86cbec..1b04ac790 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -499,6 +499,14 @@ class DataTable(ScrollView, Generic[CellType]): return segments def render_line(self, y: int) -> list[Segment]: + """Render a line of content. + + Args: + y (int): Y Coordinate of line. + + Returns: + list[Segment]: A rendered line. + """ width, height = self.content_size scroll_x, scroll_y = self.scroll_offset fixed_top_row_count = sum( @@ -515,6 +523,14 @@ class DataTable(ScrollView, Generic[CellType]): return self._render_line(y, scroll_x, scroll_x + width, style) def render_lines(self, crop: Region) -> Lines: + """Render the widget in to lines. + + Args: + crop (Region): Region within visible area to. + + Returns: + Lines: A list of list of segments + """ lines = self._styles_cache.render_widget(self, crop) return lines diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 5f3ed09a4..ea892f189 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from rich.segment import Segment from textual.color import Color From d7f463f3eb51a22e231a017004098cf82781c64c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 21:01:11 +0100 Subject: [PATCH 13/18] remove method --- src/textual/geometry.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 826aa4b15..4bca0da80 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -459,11 +459,6 @@ class Region(NamedTuple): height + expand_height * 2, ) - def enlarge(self, size: tuple[int, int]) -> Region: - add_width, add_height = size - x, y, width, height = self - return Region(x, y, width + add_width, height + add_height) - @lru_cache(maxsize=1024) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. From 0ba3ffb1718bdd01a5136fd1bc30e8ed58e6a47c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 21:15:24 +0100 Subject: [PATCH 14/18] size properties --- old examples/easing.py | 4 ++-- src/textual/css/scalar_animation.py | 2 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/vertical.py | 2 +- src/textual/screen.py | 2 +- src/textual/widget.py | 20 ++++++++++---------- src/textual/widgets/_data_table.py | 4 ++-- tests/css/test_styles.py | 2 +- tests/test_integration_layout.py | 6 +++--- tests/utilities/test_app.py | 2 +- 10 files changed, 23 insertions(+), 23 deletions(-) diff --git a/old examples/easing.py b/old examples/easing.py index 447f69e5f..e70620e05 100644 --- a/old examples/easing.py +++ b/old examples/easing.py @@ -14,8 +14,8 @@ class EasingApp(App): def watch_side(self, side: bool) -> None: """Animate when the side changes (False for left, True for right).""" - width = self.easing_view.size.width - animate_x = (width - self.placeholder.size.width) if side else 0 + width = self.easing_view.outer_size.width + animate_x = (width - self.placeholder.outer_size.width) if side else 0 self.placeholder.animate( "layout_offset_x", animate_x, easing=self.easing, duration=1 ) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 58cc4252f..ae37249f9 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -36,7 +36,7 @@ class ScalarAnimation(Animation): self.final_value = value self.easing = easing - size = widget.size + size = widget.outer_size viewport = widget.app.size self.start: Offset = getattr(styles, attribute).resolve(size, viewport) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 47c6788e7..4f6588048 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -22,7 +22,7 @@ class HorizontalLayout(Layout): add_placement = placements.append x = max_width = max_height = Fraction(0) - parent_size = parent.size + parent_size = parent.outer_size children = list(parent.children) styles = [child.styles for child in children if child.styles.width is not None] diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 023995a6a..f8ed4f152 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -20,7 +20,7 @@ class VerticalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - parent_size = parent.size + parent_size = parent.outer_size children = list(parent.children) styles = [child.styles for child in children if child.styles.height is not None] diff --git a/src/textual/screen.py b/src/textual/screen.py index acf9552ff..7ad6d3e02 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -131,7 +131,7 @@ class Screen(Widget): def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" - size = self.size if size is None else size + size = self.outer_size if size is None else size if not size: return diff --git a/src/textual/widget.py b/src/textual/widget.py index 753f74821..08abce3e8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -434,15 +434,18 @@ class Widget(DOMNode): return self.styles.gutter + self.scrollbar_gutter @property - def content_size(self) -> Size: + def size(self) -> Size: + """The size of the content area.""" return self.content_region.size @property - def size(self) -> Size: + def outer_size(self) -> Size: + """The size of the widget (including padding and border).""" return self._size @property def container_size(self) -> Size: + """The size of the container (parent widget).""" return self._container_size @property @@ -535,17 +538,14 @@ class Widget(DOMNode): """ if regions: - widget_regions = [ - region.translate(self.content_offset) for region in regions - ] + content_offset = self.content_offset + widget_regions = [region.translate(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) + self._dirty_regions.add(self.outer_size.region) def get_dirty_regions(self) -> Collection[Region]: """Get regions which require a repaint. @@ -976,13 +976,13 @@ class Widget(DOMNode): def _render_content(self) -> None: """Render all lines.""" - width, height = self.content_size + width, height = self.size renderable = self.render_styled() options = self.console.options.update_dimensions(width, height).update( highlight=False ) lines = self.console.render_lines(renderable, options, style=self.rich_style) - self._render_cache = RenderCache(self.content_size, lines) + self._render_cache = RenderCache(self.size, lines) self._dirty_regions.clear() def render_line(self, y: int) -> list[Segment]: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 1b04ac790..472c98bc1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -462,7 +462,7 @@ class DataTable(ScrollView, Generic[CellType]): list[Segment]: List of segments for rendering. """ - width = self.content_size.width + width = self.size.width try: row_index, line_no = self._get_offsets(y) @@ -507,7 +507,7 @@ class DataTable(ScrollView, Generic[CellType]): Returns: list[Segment]: A rendered line. """ - width, height = self.content_size + width, height = self.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) diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index 0d1319ae6..3ad0585cd 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -277,5 +277,5 @@ async def test_scrollbar_gutter( app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10)) await app.boot_and_shutdown() - assert text_widget.size.width == expected_text_widget_width + assert text_widget.outer_size.width == expected_text_widget_width assert container.scrollbars_enabled[0] is expects_vertical_scrollbar diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 2046a37b8..85990497f 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -144,7 +144,7 @@ async def test_composition_of_vertical_container_with_children( async with app.in_running_state(): # root widget checks: root_widget = cast(Widget, app.get_child("root")) - assert root_widget.size == expected_screen_size + assert root_widget.outer_size == expected_screen_size root_widget_region = app.screen.find_widget(root_widget).region assert root_widget_region == ( 0, @@ -158,7 +158,7 @@ async def test_composition_of_vertical_container_with_children( # placeholder widgets checks: for placeholder in app_placeholders: - assert placeholder.size == expected_placeholders_size + assert placeholder.outer_size == expected_placeholders_size assert placeholder.styles.offset.x.value == 0.0 assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x @@ -224,7 +224,7 @@ async def test_border_edge_types_impact_on_widget_size( ) assert box_inner_size == expected_box_inner_size - assert border_target.size == expected_box_size + assert border_target.outer_size == expected_box_size top_left_edge_style = app.screen.get_style_at(0, 0) top_left_edge_color = top_left_edge_style.color.name diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index a327a54c9..8e422f2e7 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -159,7 +159,7 @@ class AppTest(App): # We artificially tell the Compositor that the whole area should be refreshed screen._compositor._dirty_regions = { - Region(0, 0, screen.size.width, screen.size.height), + Region(0, 0, screen.outer_size.width, screen.outer_size.height), } screen.refresh(repaint=repaint, layout=layout) # We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return: From 20130a4ecf649fb8e1788c3b828133ea3c3e1f48 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 21:21:09 +0100 Subject: [PATCH 15/18] docstring --- src/textual/renderables/opacity.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py index 5dabac89a..22c8b5513 100644 --- a/src/textual/renderables/opacity.py +++ b/src/textual/renderables/opacity.py @@ -13,6 +13,18 @@ from textual.renderables._blend_colors import blend_colors def _get_blended_style_cached( bg_color: Color, fg_color: Color, opacity: float ) -> Style: + """Blend from one color to another. + + Cached because when a UI is static the opacity will be constant. + + Args: + bg_color (Color): Background color. + fg_color (Color): Foreground color. + opacity (float): Opacity. + + Returns: + Style: Resulting style. + """ return Style.from_color( color=blend_colors(bg_color, fg_color, ratio=opacity), bgcolor=bg_color, From 951c3c567eb643865e017138fa918923c9d22d63 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 5 Jul 2022 13:03:44 +0100 Subject: [PATCH 16/18] docstring --- src/textual/_segment_tools.py | 8 ++++---- src/textual/_styles_cache.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 3258d6c5a..df9455f8e 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -99,7 +99,7 @@ def line_pad( """Adds padding to the left and / or right of a list of segments. Args: - segments (list[Segment]): A line (list of Segments). + segments (Iterable[Segment]): A line of segments. pad_left (int): Cells to pad on the left. pad_right (int): Cells to pad on the right. style (Style): Style of padded cells. @@ -108,18 +108,18 @@ def line_pad( list[Segment]: A new line with padding. """ if pad_left and pad_right: - segments = [ + return [ Segment(" " * pad_left, style), *segments, Segment(" " * pad_right, style), ] elif pad_left: - segments = [ + return [ Segment(" " * pad_left, style), *segments, ] elif pad_right: - segments = [ + return [ *segments, Segment(" " * pad_right, style), ] diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 7d661e169..4057a0561 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -194,7 +194,7 @@ class StylesCache: render_content_line (RenderLineCallback): Callback to render a line of content. Returns: - list[Segment]: _description_ + list[Segment]: A line of segments. """ gutter = styles.gutter From 53bb1b39a8591dc3a9e9695744527288ae99161f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 5 Jul 2022 13:04:20 +0100 Subject: [PATCH 17/18] Update src/textual/geometry.py Co-authored-by: darrenburns --- src/textual/geometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 4bca0da80..11ca38254 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -323,7 +323,7 @@ class Region(NamedTuple): @property def line_span(self) -> tuple[int, int]: - """Get the star and end line number (y coord). + """Get the start and end line number (y coord). The end value is exclusive. From ca90053b4e39bdce0fc1a5319682ebc09819ac91 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 5 Jul 2022 13:20:54 +0100 Subject: [PATCH 18/18] docstring --- src/textual/_styles_cache.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 4057a0561..dde15bf72 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -31,6 +31,12 @@ RenderLineCallback: TypeAlias = Callable[[int], List[Segment]] class StylesCache: """Responsible for rendering CSS Styles and keeping a cached of rendered lines. + The render method applies border, outline, and padding set in the Styles object to widget content. + + The diagram below shows content (possibly from a Rich renderable) with padding and border. The + labels A. B. and C. indicate the code path (see comments in render_line below) chosen to render + the indicated lines. + ``` ┏━━━━━━━━━━━━━━━━━━━━━━┓◀── A. border ┃ ┃◀┐