render lines and scrollbars

This commit is contained in:
Will McGugan
2022-07-04 20:37:59 +01:00
parent f3a7c9483f
commit 2ac95c5922
7 changed files with 193 additions and 186 deletions

View File

@@ -28,9 +28,11 @@ App > Screen {
} }
DataTable { DataTable {
border: solid red; /*border:heavy red;*/
/* tint: 10% green; */
margin: 1 1; /* opacity: 50%; */
padding: 1;
margin: 1 2;
height: 12; height: 12;
} }
@@ -105,7 +107,7 @@ Tweet {
.scrollable { .scrollable {
overflow-y: scroll; overflow-y: scroll;
margin: 1 2; margin: 1 2;
height: 20; height: 20;
@@ -113,8 +115,7 @@ Tweet {
layout: vertical; layout: vertical;
} }
.code { .code {
height: auto; height: auto;
} }

View File

@@ -386,33 +386,23 @@ class Compositor:
# Add any scrollbars # Add any scrollbars
for chrome_widget, chrome_region in widget._arrange_scrollbars( for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_size container_region
): ):
map[chrome_widget] = MapGeometry( map[chrome_widget] = MapGeometry(
chrome_region + container_region.offset + layout_offset, chrome_region + layout_offset,
order, order,
clip, clip,
container_size, container_size,
container_size, container_size,
) )
if widget.is_container: map[widget] = MapGeometry(
# Add the container widget, which will render a background region + layout_offset,
map[widget] = MapGeometry( order,
region + layout_offset, clip,
order, total_region.size,
clip, container_size,
total_region.size, )
container_size,
)
else:
map[widget] = MapGeometry(
child_region + layout_offset,
order,
clip,
child_region.size,
container_size,
)
else: else:
# Add the widget to the map # Add the widget to the map

View File

@@ -10,7 +10,7 @@ from ._border import get_box, render_row
from ._segment_tools import line_crop, line_pad, line_trim from ._segment_tools import line_crop, line_pad, line_trim
from ._types import Lines from ._types import Lines
from .color import Color from .color import Color
from .geometry import Region, Size from .geometry import Spacing, Region, Size
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
from .renderables.tint import Tint from .renderables.tint import Tint
@@ -62,9 +62,18 @@ class StylesCache:
self.clear() self.clear()
def is_dirty(self, y: int) -> bool: 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 return y in self._dirty_lines
def clear(self) -> None: def clear(self) -> None:
"""Clear the styles cache (will cause the content to re-render)."""
self._cache.clear() self._cache.clear()
self._dirty_lines.clear() self._dirty_lines.clear()
@@ -79,12 +88,15 @@ class StylesCache:
Lines: Rendered lines. Lines: Rendered lines.
""" """
(base_background, base_color), (background, color) = widget.colors (base_background, base_color), (background, color) = widget.colors
padding = widget.styles.padding + widget.scrollbar_gutter
lines = self.render( lines = self.render(
widget.styles, widget.styles,
widget.region.size, widget.region.size,
base_background, base_background,
background, background,
widget.render_line, widget.render_line,
content_size=widget.content_region.size,
padding=padding,
crop=crop, crop=crop,
) )
return lines return lines
@@ -95,35 +107,33 @@ class StylesCache:
size: Size, size: Size,
base_background: Color, base_background: Color,
background: Color, background: Color,
render_line: RenderLineCallback, render_content_line: RenderLineCallback,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None, crop: Region | None = None,
) -> Lines: ) -> Lines:
"""Render a given region. """Render a widget content plus CSS styles.
Args: 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: Returns:
Lines: List of Segments, one per line. Lines: Rendered lines.
""" """
return self._render( if content_size is None:
size, content_size = size
size.region if crop is None else crop, if padding is None:
styles, padding = styles.padding
base_background, if crop is None:
background, crop = size.region
render_line,
)
def _render(
self,
size: Size,
crop: Region,
styles: StylesBase,
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
) -> Lines:
width, height = size width, height = size
if width != self._width: if width != self._width:
self.clear() self.clear()
@@ -137,7 +147,14 @@ class StylesCache:
for y in crop.line_range: for y in crop.line_range:
if is_dirty(y) or y not in self._cache: if is_dirty(y) or y not in self._cache:
line = render_line( 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)) line = list(simplify(line))
self._cache[y] = line self._cache[y] = line
@@ -158,27 +175,34 @@ class StylesCache:
styles: StylesBase, styles: StylesBase,
y: int, y: int,
size: Size, size: Size,
content_size: Size,
padding: Spacing,
base_background: Color, base_background: Color,
background: Color, background: Color,
render_content_line: RenderLineCallback, render_content_line: RenderLineCallback,
) -> list[Segment]: ) -> list[Segment]:
"""Render a styled lines. """Render a styled line.
Args: Args:
styles (RenderStyles): Styles object. styles (StylesBase): Styles object.
y (int): The y coordinate of the line (relative to top of widget) y (int): The y coordinate of the line (relative to widget screen offset).
size (Size): Size of the widget. size (Size): Size of the widget.
base_background (Color): The background color beneath this widget. content_size (Size): Size of the content area.
background (Color): Background color of the widget. 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: Returns:
list[Segment]: A line as a list of segments. list[Segment]: _description_
""" """
gutter = styles.gutter gutter = styles.gutter
width, height = size 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_top, border_top_color),
(border_right, border_right_color), (border_right, border_right_color),
@@ -252,7 +276,11 @@ class StylesCache:
line = [Segment(" " * width, background_style)] line = [Segment(" " * width, background_style)]
else: else:
# Content with border and padding (C) # 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: if inner:
line = Segment.apply_style(line, inner) line = Segment.apply_style(line, inner)
line = line_pad(line, pad_left, pad_right, inner) line = line_pad(line, pad_left, pad_right, inner)

View File

@@ -459,6 +459,11 @@ class Region(NamedTuple):
height + expand_height * 2, 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) @lru_cache(maxsize=1024)
def overlaps(self, other: Region) -> bool: def overlaps(self, other: Region) -> bool:
"""Check if another region overlaps this region. """Check if another region overlaps this region.

View File

@@ -405,11 +405,6 @@ class Widget(DOMNode):
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
return enabled 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 @property
def scrollbar_size_vertical(self) -> int: def scrollbar_size_vertical(self) -> int:
"""Get the width used by the *vertical* scrollbar.""" """Get the width used by the *vertical* scrollbar."""
@@ -426,6 +421,108 @@ class Widget(DOMNode):
else 0 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: def _set_dirty(self, *regions: Region) -> None:
"""Set the Widget as 'dirty' (requiring re-paint). """Set the Widget as 'dirty' (requiring re-paint).
@@ -731,17 +828,17 @@ class Widget(DOMNode):
region, _ = region.split_horizontal(-scrollbar_size_horizontal) region, _ = region.split_horizontal(-scrollbar_size_horizontal)
return region 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. """Arrange the 'chrome' widgets (typically scrollbars) for a layout element.
Args: Args:
size (Size): Size of the containing region. region (Region): The containing region.
Returns: Returns:
Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region. Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region.
""" """
region = size.region
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
scrollbar_size_horizontal = self.scrollbar_size_horizontal scrollbar_size_horizontal = self.scrollbar_size_horizontal
@@ -845,95 +942,6 @@ class Widget(DOMNode):
return renderable 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: def watch_mouse_over(self, value: bool) -> None:
"""Update from CSS if mouse over state changes.""" """Update from CSS if mouse over state changes."""
self.app.update_styles() self.app.update_styles()

View File

@@ -518,38 +518,6 @@ class DataTable(ScrollView, Generic[CellType]):
lines = self._styles_cache.render_widget(self, crop) lines = self._styles_cache.render_widget(self, crop)
return lines 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): def on_mouse_move(self, event: events.MouseMove):
meta = event.style.meta meta = event.style.meta
if meta: if meta:
@@ -573,7 +541,6 @@ class DataTable(ScrollView, Generic[CellType]):
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
region = self._get_cell_region(self.cursor_row, self.cursor_column) region = self._get_cell_region(self.cursor_row, self.cursor_column)
region.translate(self.content_offset)
spacing = self._get_cell_border() spacing = self._get_cell_border()
self.scroll_to_region(region, animate=animate, spacing=spacing) self.scroll_to_region(region, animate=animate, spacing=spacing)

View File

@@ -37,6 +37,7 @@ def test_no_styles():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
) )
expected = [ expected = [
[Segment("foo", styles.rich_style)], [Segment("foo", styles.rich_style)],
@@ -61,6 +62,7 @@ def test_border():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
) )
text_content = _extract_content(lines) text_content = _extract_content(lines)
@@ -91,6 +93,7 @@ def test_padding():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
) )
text_content = _extract_content(lines) text_content = _extract_content(lines)
@@ -122,6 +125,7 @@ def test_padding_border():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
) )
text_content = _extract_content(lines) text_content = _extract_content(lines)
@@ -154,6 +158,7 @@ def test_outline():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
) )
text_content = _extract_content(lines) text_content = _extract_content(lines)
@@ -181,6 +186,7 @@ def test_crop():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
content.__getitem__, content.__getitem__,
content_size=Size(3, 3),
crop=Region(2, 2, 3, 3), crop=Region(2, 2, 3, 3),
) )
text_content = _extract_content(lines) text_content = _extract_content(lines)
@@ -239,6 +245,7 @@ def test_dirty_cache():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
get_content_line, get_content_line,
content_size=Size(3, 3),
) )
assert rendered_lines == [] assert rendered_lines == []
del rendered_lines[:] del rendered_lines[:]
@@ -254,6 +261,7 @@ def test_dirty_cache():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
get_content_line, get_content_line,
content_size=Size(3, 3),
) )
assert rendered_lines == [0, 1] assert rendered_lines == [0, 1]
text_content = _extract_content(lines) text_content = _extract_content(lines)