mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
render lines and scrollbars
This commit is contained in:
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user