Merge pull request #602 from Textualize/render-cache

Render cache
This commit is contained in:
Will McGugan
2022-07-05 13:27:22 +01:00
committed by GitHub
28 changed files with 1113 additions and 264 deletions

View File

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

View File

@@ -15,9 +15,10 @@
scrollbar-size-vertical: 2;
}
/* *:hover {
tint: red 30%;
} */
*:hover {
/* tint: 30% red;
/* outline: heavy red; */
}
App > Screen {
layout: dock;
@@ -27,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;
}
@@ -113,7 +116,6 @@ Tweet {
}
.code {
height: auto;
}
@@ -224,13 +226,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;
}

View File

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

View File

@@ -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)),
@@ -68,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:
@@ -122,6 +128,31 @@ 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]
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.
@@ -135,13 +166,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
(
@@ -149,7 +180,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

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
@@ -657,13 +647,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)

View File

@@ -4,12 +4,17 @@ 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
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:
@@ -33,7 +38,7 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
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:
@@ -58,3 +63,64 @@ 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()
return segments
def line_pad(
segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style
) -> list[Segment]:
"""Adds padding to the left and / or right of a list of segments.
Args:
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.
Returns:
list[Segment]: A new line with padding.
"""
if pad_left and pad_right:
return [
Segment(" " * pad_left, style),
*segments,
Segment(" " * pad_right, style),
]
elif pad_left:
return [
Segment(" " * pad_left, style),
*segments,
]
elif pad_right:
return [
*segments,
Segment(" " * pad_right, style),
]
return list(segments)

View File

@@ -0,0 +1,339 @@
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Callable, Iterable, List
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, Spacing
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.
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
┃ ┃◀┐
┃ ┃ └─ 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:
"""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()
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
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
def render(
self,
styles: StylesBase,
size: Size,
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None,
) -> Lines:
"""Render a widget content plus CSS styles.
Args:
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: Rendered lines.
"""
if content_size is None:
content_size = size
if padding is None:
padding = styles.padding
if crop is None:
crop = size.region
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,
content_size,
padding,
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,
content_size: Size,
padding: Spacing,
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
) -> list[Segment]:
"""Render a styled line.
Args:
styles (StylesBase): Styles object.
y (int): The y coordinate of the line (relative to widget screen offset).
size (Size): Size 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 of segments.
"""
gutter = styles.gutter
width, height = size
content_width, content_height = content_size
pad_top, pad_right, pad_bottom, pad_left = 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)
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)
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)

View File

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

View File

@@ -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):
@@ -397,8 +399,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)

View File

@@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final[set[EdgeType]] = {
"none",
"hidden",
"ascii",
"round",
"blank",
"solid",

View File

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

View File

@@ -168,6 +168,7 @@ class StylesBase(ABC):
"max_height",
"color",
"background",
"opacity",
"tint",
"scrollbar_color",
"scrollbar_color_hover",
@@ -261,12 +262,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'."""

View File

@@ -14,6 +14,7 @@ else:
Edge = Literal["top", "right", "bottom", "left"]
EdgeType = Literal[
"",
"ascii",
"none",
"hidden",
"blank",

View File

@@ -139,6 +139,11 @@ class Size(NamedTuple):
width, height = self
return Region(0, 0, width, height)
@property
def line_range(self) -> range:
"""Get a range covering lines."""
return range(self.height)
def __add__(self, other: object) -> Size:
if isinstance(other, tuple):
width, height = self
@@ -306,21 +311,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 start 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).
@@ -381,12 +386,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)

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import functools
from typing import Iterable
from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
@@ -8,51 +9,75 @@ from rich.style import Style
from textual.renderables._blend_colors import blend_colors
@functools.lru_cache(maxsize=1024)
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,
)
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.
"""
def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None:
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)
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
) -> Style:
return Style.from_color(
color=blend_colors(bg_color, fg_color, ratio=opacity),
bgcolor=bg_color,
)
return self.process_segments(segments, self.opacity)
if __name__ == "__main__":

View File

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

View File

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

View File

@@ -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,
)
@@ -17,38 +17,35 @@ 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
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 ._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,
)
@@ -118,6 +115,8 @@ class Widget(DOMNode):
self._arrangement: ArrangeResult | None = None
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
self._styles_cache = StylesCache()
super().__init__(name=name, id=id, classes=classes)
self.add_children(*children)
@@ -406,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."""
@@ -427,6 +421,111 @@ 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 size(self) -> Size:
"""The size of the content area."""
return self.content_region.size
@property
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
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).
@@ -439,12 +538,14 @@ class Widget(DOMNode):
"""
if regions:
self._dirty_regions.update(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()
# TODO: Does this need to be content region?
# self._dirty_regions.append(self.size.region)
self._dirty_regions.add(self.size.region)
self._styles_cache.clear()
self._dirty_regions.add(self.outer_size.region)
def get_dirty_regions(self) -> Collection[Region]:
"""Get regions which require a repaint.
@@ -664,7 +765,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)
@@ -727,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
@@ -833,94 +934,14 @@ 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 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.content_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
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()
@@ -953,23 +974,30 @@ class Widget(DOMNode):
else:
self.refresh()
def _render_lines(self) -> None:
def _render_content(self) -> None:
"""Render all lines."""
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)
lines = self.console.render_lines(renderable, options, style=self.rich_style)
self._render_cache = RenderCache(self.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.
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
def render_lines(self, crop: Region) -> Lines:
"""Render the widget in to lines.
@@ -980,12 +1008,7 @@ class Widget(DOMNode):
Returns:
Lines: A list of list of segments
"""
if self._dirty_regions:
self._render_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:

View File

@@ -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.size.width
try:
row_index, line_no = self._get_offsets(y)
@@ -496,36 +498,40 @@ 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.
def render_line(self, y: int) -> list[Segment]:
"""Render a line of content.
Args:
crop (Region): Region to crop to.
y (int): Y Coordinate of line.
Returns:
Lines: A list of segments for every line within crop region.
list[Segment]: A rendered line.
"""
scroll_y = self.scroll_offset.y
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
base_style = self.rich_style
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)
)
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:
"""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
def on_mouse_move(self, event: events.MouseMove):

View File

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

25
tests/test_border.py Normal file
View File

@@ -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],
]

View File

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

View File

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

View File

@@ -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, line_pad
def test_line_crop():
@@ -62,3 +62,52 @@ 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) == []
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

270
tests/test_styles_cache.py Normal file
View File

@@ -0,0 +1,270 @@
from __future__ import annotations
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__,
content_size=Size(3, 3),
)
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__,
content_size=Size(3, 3),
)
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__,
content_size=Size(3, 3),
)
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__,
content_size=Size(3, 3),
)
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__,
content_size=Size(3, 3),
)
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__,
content_size=Size(3, 3),
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,
content_size=Size(3, 3),
)
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,
content_size=Size(3, 3),
)
assert rendered_lines == [0, 1]
text_content = _extract_content(lines)
assert text_content == expected_text

View File

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