From 2ac95c5922d28c8c74d15bd3b7f2bbb541a1c669 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 4 Jul 2022 20:37:59 +0100 Subject: [PATCH] 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)