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: def watch_side(self, side: bool) -> None:
"""Animate when the side changes (False for left, True for right).""" """Animate when the side changes (False for left, True for right)."""
width = self.easing_view.size.width width = self.easing_view.outer_size.width
animate_x = (width - self.placeholder.size.width) if side else 0 animate_x = (width - self.placeholder.outer_size.width) if side else 0
self.placeholder.animate( self.placeholder.animate(
"layout_offset_x", animate_x, easing=self.easing, duration=1 "layout_offset_x", animate_x, easing=self.easing, duration=1
) )

View File

@@ -15,9 +15,10 @@
scrollbar-size-vertical: 2; scrollbar-size-vertical: 2;
} }
/* *:hover { *:hover {
tint: red 30%; /* tint: 30% red;
} */ /* outline: heavy red; */
}
App > Screen { App > Screen {
layout: dock; layout: dock;
@@ -27,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;
} }
@@ -113,7 +116,6 @@ Tweet {
} }
.code { .code {
height: auto; height: auto;
} }
@@ -224,13 +226,15 @@ Warning {
Success { Success {
width: 100%; width: 100%;
height:3; width:90%;
height:auto;
box-sizing: border-box; box-sizing: border-box;
background: $success-lighten-3; background: $success-lighten-3;
color: $text-success-lighten-3-fade-1; color: $text-success-lighten-3-fade-1;
border-top: hkey $success; border-top: hkey $success;
border-bottom: hkey $success; border-bottom: hkey $success;
margin: 1 2;
text-style: bold; text-style: bold;
align-horizontal: center; align-horizontal: center;
} }

View File

@@ -88,7 +88,7 @@ class Warning(Widget):
class Success(Widget): class Success(Widget):
def render(self) -> Text: 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"): 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) # - 2nd string represents (mid1, mid2, mid3)
# - 3rd string represents (bottom1, bottom2, bottom3) # - 3rd string represents (bottom1, bottom2, bottom3)
"": (" ", " ", " "), "": (" ", " ", " "),
"ascii": ("+-+", "| |", "+-+"),
"none": (" ", " ", " "), "none": (" ", " ", " "),
"hidden": (" ", " ", " "), "hidden": (" ", " ", " "),
"blank": (" ", " ", " "), "blank": (" ", " ", " "),
@@ -48,6 +49,7 @@ BORDER_LOCATIONS: dict[
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]] EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
] = { ] = {
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), "": ((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)), "none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hidden": ((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)), "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]] 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) @lru_cache(maxsize=1024)
def get_box( def get_box(
name: EdgeType, inner_style: Style, outer_style: Style, style: Style name: EdgeType, inner_style: Style, outer_style: Style, style: Style
) -> tuple[ ) -> BoxSegments:
tuple[Segment, Segment, Segment],
tuple[Segment, Segment, Segment],
tuple[Segment, Segment, Segment],
]:
"""Get segments used to render a box. """Get segments used to render a box.
Args: 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 @rich.repr.auto
class Border: class Border:
"""Renders Textual CSS borders. """Renders Textual CSS borders.
@@ -135,13 +166,13 @@ class Border:
def __init__( def __init__(
self, self,
renderable: RenderableType, renderable: RenderableType,
edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle], borders: Borders,
inner_color: Color, inner_color: Color,
outer_color: Color, outer_color: Color,
outline: bool = False, outline: bool = False,
): ):
self.renderable = renderable self.renderable = renderable
self.edge_styles = edge_styles self.edge_styles = borders
self.outline = outline self.outline = outline
( (
@@ -149,7 +180,7 @@ class Border:
(right, right_color), (right, right_color),
(bottom, bottom_color), (bottom, bottom_color),
(left, left_color), (left, left_color),
) = edge_styles ) = borders
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
self._sides = (top, right, bottom, left) self._sides = (top, right, bottom, left)
from_color = Style.from_color from_color = Style.from_color

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
@@ -657,13 +647,13 @@ class Compositor:
for region, clip, lines in renders: for region, clip, lines in renders:
render_region = intersection(region, clip) 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): if not is_rendered_line(y):
continue continue
chops_line = chops[y] chops_line = chops[y]
first_cut, last_cut = render_region.x_extents first_cut, last_cut = render_region.column_span
cuts_line = cuts[y] cuts_line = cuts[y]
final_cuts = [ final_cuts = [
cut for cut in cuts_line if (last_cut >= cut >= first_cut) 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 __future__ import annotations
from typing import Iterable
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style
from ._cells import cell_len 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. """Crops a list of segments between two cell offsets.
Args: Args:
@@ -33,7 +38,7 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
for segment in iter_segments: for segment in iter_segments:
end_pos = pos + _cell_len(segment.text) end_pos = pos + _cell_len(segment.text)
if end_pos > start: if end_pos > start:
segment = segment.split_cells(start - pos)[-1] segment = segment.split_cells(start - pos)[1]
break break
pos = end_pos pos = end_pos
else: else:
@@ -58,3 +63,64 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
segment = next(iter_segments, None) segment = next(iter_segments, None)
return output_segments 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. `rich_color` attribute to perform a conversion.
I'm not entirely happy with burdening the user with two similar color classes. In a future 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". it's style. Example types are "rounded", "solid", and "dashed".
""" """
box_type, color = obj.get_rule(self.name) or ("", self._default_color) box_type, color = obj.get_rule(self.name) or ("", self._default_color)
if box_type in {"none", "hidden"}:
box_type = ""
return (box_type, color) return (box_type, color)
def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None): def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None):
@@ -397,8 +399,8 @@ class BorderProperty:
_border1, _border2, _border3, _border4 = ( _border1, _border2, _border3, _border4 = (
normalize_border_value(border[0]), normalize_border_value(border[0]),
normalize_border_value(border[1]), normalize_border_value(border[1]),
normalize_border_value(border[2]),
normalize_border_value(border[3]), normalize_border_value(border[3]),
normalize_border_value(border[4]),
) )
setattr(obj, top, _border1) setattr(obj, top, _border1)
setattr(obj, right, _border2) setattr(obj, right, _border2)

View File

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

View File

@@ -36,7 +36,7 @@ class ScalarAnimation(Animation):
self.final_value = value self.final_value = value
self.easing = easing self.easing = easing
size = widget.size size = widget.outer_size
viewport = widget.app.size viewport = widget.app.size
self.start: Offset = getattr(styles, attribute).resolve(size, viewport) self.start: Offset = getattr(styles, attribute).resolve(size, viewport)

View File

@@ -168,6 +168,7 @@ class StylesBase(ABC):
"max_height", "max_height",
"color", "color",
"background", "background",
"opacity",
"tint", "tint",
"scrollbar_color", "scrollbar_color",
"scrollbar_color_hover", "scrollbar_color_hover",
@@ -261,12 +262,6 @@ class StylesBase(ABC):
spacing = self.padding + self.border.spacing spacing = self.padding + self.border.spacing
return 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 @property
def auto_dimensions(self) -> bool: def auto_dimensions(self) -> bool:
"""Check if width or height are set to 'auto'.""" """Check if width or height are set to 'auto'."""

View File

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

View File

@@ -139,6 +139,11 @@ class Size(NamedTuple):
width, height = self width, height = self
return Region(0, 0, width, height) 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: def __add__(self, other: object) -> Size:
if isinstance(other, tuple): if isinstance(other, tuple):
width, height = self width, height = self
@@ -306,21 +311,21 @@ class Region(NamedTuple):
return bool(self.width and self.height) return bool(self.width and self.height)
@property @property
def x_extents(self) -> tuple[int, int]: def column_span(self) -> tuple[int, int]:
"""Get the starting and ending x coord. """Get the start and end column (x coord).
The end value is non inclusive. The end value is exclusive.
Returns: 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) return (self.x, self.x + self.width)
@property @property
def y_extents(self) -> tuple[int, int]: def line_span(self) -> tuple[int, int]:
"""Get the starting and ending x coord. """Get the start and end line number (y coord).
The end value is non inclusive. The end value is exclusive.
Returns: Returns:
tuple[int, int]: Pair of y coordinates (line numbers). tuple[int, int]: Pair of y coordinates (line numbers).
@@ -381,12 +386,12 @@ class Region(NamedTuple):
return x, y, x + width, y + height return x, y, x + width, y + height
@property @property
def x_range(self) -> range: def column_range(self) -> range:
"""A range object for X coordinates.""" """A range object for X coordinates."""
return range(self.x, self.x + self.width) return range(self.x, self.x + self.width)
@property @property
def y_range(self) -> range: def line_range(self) -> range:
"""A range object for Y coordinates.""" """A range object for Y coordinates."""
return range(self.y, self.y + self.height) return range(self.y, self.y + self.height)

View File

@@ -22,7 +22,7 @@ class HorizontalLayout(Layout):
add_placement = placements.append add_placement = placements.append
x = max_width = max_height = Fraction(0) x = max_width = max_height = Fraction(0)
parent_size = parent.size parent_size = parent.outer_size
children = list(parent.children) children = list(parent.children)
styles = [child.styles for child in children if child.styles.width is not None] 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] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
parent_size = parent.size parent_size = parent.outer_size
children = list(parent.children) children = list(parent.children)
styles = [child.styles for child in children if child.styles.height is not None] styles = [child.styles for child in children if child.styles.height is not None]

View File

@@ -1,4 +1,5 @@
import functools import functools
from typing import Iterable
from rich.color import Color from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType 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 from textual.renderables._blend_colors import blend_colors
class Opacity: @functools.lru_cache(maxsize=1024)
"""Wrap a renderable to blend foreground color into the background color. 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: Args:
renderable (RenderableType): The RenderableType to manipulate. bg_color (Color): Background color.
opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. 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: 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.
"""
self.renderable = renderable self.renderable = renderable
self.opacity = opacity 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__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
) -> RenderResult: ) -> RenderResult:
segments = console.render(self.renderable, options) segments = console.render(self.renderable, options)
opacity = self.opacity return self.process_segments(segments, 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,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
from rich.segment import Segment from rich.segment import Segment
@@ -12,7 +13,7 @@ class Tint:
"""Applies a color on top of an existing renderable.""" """Applies a color on top of an existing renderable."""
def __init__(self, renderable: RenderableType, color: Color) -> None: def __init__(self, renderable: RenderableType, color: Color) -> None:
"""_summary_ """Wrap a renderable to apply a tint color.
Args: Args:
renderable (RenderableType): A renderable. renderable (RenderableType): A renderable.
@@ -21,20 +22,29 @@ class Tint:
self.renderable = renderable self.renderable = renderable
self.color = color self.color = color
def __rich_console__( @classmethod
self, console: Console, options: ConsoleOptions def process_segments(
) -> RenderResult: cls, segments: Iterable[Segment], color: Color
segments = console.render(self.renderable, options) ) -> 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 from_rich_color = Color.from_rich_color
style_from_color = Style.from_color style_from_color = Style.from_color
_Segment = Segment
for segment in segments: for segment in segments:
text, style, control = segment text, style, control = segment
if control or style is None: if control or style is None:
yield segment yield segment
else: else:
yield Segment( yield _Segment(
text, text,
( (
style style
@@ -45,3 +55,10 @@ class Tint:
), ),
control, 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: def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
"""Refresh the layout (can change size and positions of widgets).""" """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: if not size:
return return

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from fractions import Fraction from fractions import Fraction
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
Awaitable, Awaitable,
Callable,
ClassVar, ClassVar,
Collection, Collection,
TYPE_CHECKING,
Callable,
Iterable, Iterable,
NamedTuple, NamedTuple,
) )
@@ -17,38 +17,35 @@ from rich.align import Align
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.measure import Measurement from rich.measure import Measurement
from rich.padding import Padding from rich.padding import Padding
from rich.segment import Segment
from rich.style import Style from rich.style import Style
from . import errors from . import errors, events, messages
from . import events
from ._animator import BoundAnimator from ._animator import BoundAnimator
from ._border import Border from ._border import Border
from .box_model import BoxModel, get_box_model
from ._context import active_app 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 ._types import Lines
from .box_model import BoxModel, get_box_model
from .dom import DOMNode from .dom import DOMNode
from ._layout import ArrangeResult from .geometry import Offset, Region, Size, Spacing, clamp
from .geometry import clamp, Offset, Region, Size, Spacing
from .layouts.vertical import VerticalLayout from .layouts.vertical import VerticalLayout
from .message import Message from .message import Message
from . import messages
from ._layout import Layout
from .reactive import Reactive, watch from .reactive import Reactive, watch
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
from .renderables.tint import Tint from .renderables.tint import Tint
from ._segment_tools import line_crop
from .css.styles import Styles
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App, ComposeResult from .app import App, ComposeResult
from .scrollbar import ( from .scrollbar import (
ScrollBar, ScrollBar,
ScrollTo,
ScrollUp,
ScrollDown, ScrollDown,
ScrollLeft, ScrollLeft,
ScrollRight, ScrollRight,
ScrollTo,
ScrollUp,
) )
@@ -118,6 +115,8 @@ class Widget(DOMNode):
self._arrangement: ArrangeResult | None = None self._arrangement: ArrangeResult | None = None
self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
self._styles_cache = StylesCache()
super().__init__(name=name, id=id, classes=classes) super().__init__(name=name, id=id, classes=classes)
self.add_children(*children) self.add_children(*children)
@@ -406,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."""
@@ -427,6 +421,111 @@ 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 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: def _set_dirty(self, *regions: Region) -> None:
"""Set the Widget as 'dirty' (requiring re-paint). """Set the Widget as 'dirty' (requiring re-paint).
@@ -439,12 +538,14 @@ class Widget(DOMNode):
""" """
if regions: 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: else:
self._dirty_regions.clear() self._dirty_regions.clear()
# TODO: Does this need to be content region? self._styles_cache.clear()
# self._dirty_regions.append(self.size.region) self._dirty_regions.add(self.outer_size.region)
self._dirty_regions.add(self.size.region)
def get_dirty_regions(self) -> Collection[Region]: def get_dirty_regions(self) -> Collection[Region]:
"""Get regions which require a repaint. """Get regions which require a repaint.
@@ -664,7 +765,7 @@ class Widget(DOMNode):
bool: True if the window was scrolled. 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: if spacing is not None:
window = window.shrink(spacing) window = window.shrink(spacing)
delta = Region.get_scroll_to_visible(window, region) delta = Region.get_scroll_to_visible(window, region)
@@ -727,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
@@ -833,94 +934,14 @@ class Widget(DOMNode):
""" """
renderable = self.render() 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 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: 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()
@@ -953,23 +974,30 @@ class Widget(DOMNode):
else: else:
self.refresh() self.refresh()
def _render_lines(self) -> None: def _render_content(self) -> None:
"""Render all lines.""" """Render all lines."""
width, height = self.size width, height = self.size
renderable = self.render_styled() renderable = self.render_styled()
options = self.console.options.update_dimensions(width, height).update( options = self.console.options.update_dimensions(width, height).update(
highlight=False 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._render_cache = RenderCache(self.size, lines)
self._dirty_regions.clear() self._dirty_regions.clear()
def _crop_lines(self, lines: Lines, x1, x2) -> Lines: def render_line(self, y: int) -> list[Segment]:
width = self.size.width """Render a line of content.
if (x1, x2) != (0, width):
_line_crop = line_crop Args:
lines = [_line_crop(line, x1, x2, width) for line in lines] y (int): Y Coordinate of line.
return lines
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: def render_lines(self, crop: Region) -> Lines:
"""Render the widget in to lines. """Render the widget in to lines.
@@ -980,12 +1008,7 @@ class Widget(DOMNode):
Returns: Returns:
Lines: A list of list of segments Lines: A list of list of segments
""" """
if self._dirty_regions: lines = self._styles_cache.render_widget(self, crop)
self._render_lines()
x1, y1, x2, y2 = crop.corners
lines = self._render_cache.lines[y1:y2]
lines = self._crop_lines(lines, x1, x2)
return lines return lines
def get_style_at(self, x: int, y: int) -> Style: 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._row_render_cache.clear()
self._cell_render_cache.clear() self._cell_render_cache.clear()
self._line_cache.clear() self._line_cache.clear()
self._styles_cache.clear()
def get_row_height(self, row_index: int) -> int: def get_row_height(self, row_index: int) -> int:
if row_index == -1: if row_index == -1:
@@ -264,7 +265,8 @@ class DataTable(ScrollView, Generic[CellType]):
y = row.y y = row.y
if self.show_header: if self.show_header:
y += self.header_height 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: def add_column(self, label: TextType, *, width: int = 10) -> None:
"""Add a column to the table. """Add a column to the table.
@@ -460,7 +462,7 @@ class DataTable(ScrollView, Generic[CellType]):
list[Segment]: List of segments for rendering. list[Segment]: List of segments for rendering.
""" """
width = self.region.width width = self.size.width
try: try:
row_index, line_no = self._get_offsets(y) row_index, line_no = self._get_offsets(y)
@@ -496,36 +498,40 @@ class DataTable(ScrollView, Generic[CellType]):
self._line_cache[cache_key] = simplified_segments self._line_cache[cache_key] = simplified_segments
return segments return segments
def render_lines(self, crop: Region) -> Lines: def render_line(self, y: int) -> list[Segment]:
"""Render lines within a given region. """Render a line of content.
Args: Args:
crop (Region): Region to crop to. y (int): Y Coordinate of line.
Returns: Returns:
Lines: A list of segments for every line within crop region. list[Segment]: A rendered line.
""" """
scroll_y = self.scroll_offset.y width, height = self.size
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners scroll_x, scroll_y = self.scroll_offset
base_style = self.rich_style
fixed_top_row_count = sum( fixed_top_row_count = sum(
self.get_row_height(row_index) for row_index in range(self.fixed_rows) self.get_row_height(row_index) for row_index in range(self.fixed_rows)
) )
if self.show_header: if self.show_header:
fixed_top_row_count += self.get_row_height(-1) fixed_top_row_count += self.get_row_height(-1)
render_line = self._render_line style = self.rich_style
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 >= fixed_top_row_count:
if y - scroll_y < fixed_top_row_count: y += scroll_y
lines[line_index] = fixed_lines[line_index]
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 return lines
def on_mouse_move(self, event: events.MouseMove): 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)) app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
await app.boot_and_shutdown() 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 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(): 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(): 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(): def test_region_x_max():
@@ -294,12 +294,12 @@ def test_region_y_max():
assert Region(5, 10, 20, 30).bottom == 40 assert Region(5, 10, 20, 30).bottom == 40
def test_region_x_range(): def test_region_columns_range():
assert Region(5, 10, 20, 30).x_range == range(5, 25) assert Region(5, 10, 20, 30).column_range == range(5, 25)
def test_region_y_range(): def test_region_lines_range():
assert Region(5, 10, 20, 30).y_range == range(10, 40) assert Region(5, 10, 20, 30).line_range == range(10, 40)
def test_region_reset_offset(): 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(): async with app.in_running_state():
# root widget checks: # root widget checks:
root_widget = cast(Widget, app.get_child("root")) 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 root_widget_region = app.screen.find_widget(root_widget).region
assert root_widget_region == ( assert root_widget_region == (
0, 0,
@@ -158,7 +158,7 @@ async def test_composition_of_vertical_container_with_children(
# placeholder widgets checks: # placeholder widgets checks:
for placeholder in app_placeholders: 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 placeholder.styles.offset.x.value == 0.0
assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x 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 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_style = app.screen.get_style_at(0, 0)
top_left_edge_color = top_left_edge_style.color.name 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 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(): def test_line_crop():
@@ -62,3 +62,52 @@ def test_line_crop_edge_2():
expected = [] expected = []
print(repr(result)) print(repr(result))
assert result == expected 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 # We artificially tell the Compositor that the whole area should be refreshed
screen._compositor._dirty_regions = { 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) 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: # We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return: