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 {
border: solid red;
margin: 1 1;
/*border:heavy red;*/
/* tint: 10% green; */
/* opacity: 50%; */
padding: 1;
margin: 1 2;
height: 12;
}
@@ -114,7 +116,6 @@ Tweet {
}
.code {
height: auto;
}

View File

@@ -386,18 +386,16 @@ 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,
@@ -405,14 +403,6 @@ class Compositor:
total_region.size,
container_size,
)
else:
map[widget] = MapGeometry(
child_region + layout_offset,
order,
clip,
child_region.size,
container_size,
)
else:
# 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 ._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)
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)

View File

@@ -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.

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)