apply style to widget

This commit is contained in:
Will McGugan
2022-07-04 14:48:28 +01:00
parent 97c58a7b0a
commit f3a7c9483f
15 changed files with 701 additions and 325 deletions

View File

@@ -16,7 +16,7 @@
}
*:hover {
tint: 30% red;
/* tint: 30% red;
/* outline: heavy red; */
}

View File

@@ -115,7 +115,7 @@ class BasicApp(App, css_path="basic.css"):
Static(Syntax(CODE, "python"), classes="code"),
classes="scrollable",
),
# table,
table,
Error(),
Tweet(TweetBody(), classes="scrollbar-size-custom"),
Warning(),

View File

@@ -131,6 +131,17 @@ 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]

View File

@@ -95,7 +95,7 @@ def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]:
def line_pad(
segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style
) -> Iterable[Segment]:
) -> list[Segment]:
"""Adds padding to the left and / or right of a list of segments.
Args:
@@ -123,4 +123,4 @@ def line_pad(
*segments,
Segment(" " * pad_right, style),
]
return segments
return list(segments)

View File

@@ -0,0 +1,305 @@
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Callable, Iterable
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
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.
```
┏━━━━━━━━━━━━━━━━━━━━━━┓◀── 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:
return y in self._dirty_lines
def clear(self) -> None:
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
lines = self.render(
widget.styles,
widget.region.size,
base_background,
background,
widget.render_line,
crop=crop,
)
return lines
def render(
self,
styles: StylesBase,
size: Size,
base_background: Color,
background: Color,
render_line: RenderLineCallback,
crop: Region | None = None,
) -> Lines:
"""Render a given region.
Args:
region (Region): A region in the screen to render.
Returns:
Lines: List of Segments, one per line.
"""
return self._render(
size,
size.region if crop is None else crop,
styles,
base_background,
background,
render_line,
)
def _render(
self,
size: Size,
crop: Region,
styles: StylesBase,
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
) -> Lines:
width, height = size
if width != self._width:
self.clear()
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, 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,
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
) -> list[Segment]:
"""Render a styled lines.
Args:
styles (RenderStyles): Styles object.
y (int): The y coordinate of the line (relative to top of widget)
size (Size): Size of the widget.
base_background (Color): The background color beneath this widget.
background (Color): Background color of the widget.
Returns:
list[Segment]: A line as a list of segments.
"""
gutter = styles.gutter
width, height = size
pad_top, pad_right, pad_bottom, pad_left = styles.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)
line = render_content_line(y - gutter.top)
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

@@ -1,260 +0,0 @@
from __future__ import annotations
from rich.style import Style
from typing import Iterable, TYPE_CHECKING
from rich.segment import Segment
from ._border import get_box, render_row
from .color import Color
from .css.types import EdgeType
from .renderables.opacity import Opacity
from .renderables.tint import Tint
from ._segment_tools import line_crop, line_pad, line_trim
from ._types import Lines
from .geometry import Region, Size
if TYPE_CHECKING:
from .css.styles import RenderStyles
from .widget import Widget
class StylesRenderer:
"""Responsible for rendering CSS Styles and keeping a cached of rendered lines."""
def __init__(self, widget: Widget) -> None:
self._widget = widget
self._cache: dict[int, list[Segment]] = {}
self._dirty_lines: set[int] = set()
def set_dirty(self, *regions: Region) -> None:
"""Add a dirty region, or set the entire widget as dirty."""
if regions:
for region in regions:
self._dirty_lines.update(region.line_range)
else:
self._dirty_lines.clear()
self._dirty_lines.update(self._widget.size.lines_range)
def render(self, region: Region) -> Lines:
widget = self._widget
styles = widget.styles
size = widget.size
(base_background, base_color), (background, color) = widget.colors
return self._render(size, region, styles, base_background, background)
def _render(
self,
size: Size,
region: Region,
styles: RenderStyles,
base_background: Color,
background: Color,
):
width, height = size
lines: Lines = []
add_line = lines.append
simplify = Segment.simplify
is_dirty = self._dirty_lines.__contains__
render_line = self.render_line
for y in region.line_range:
if is_dirty(y) or y not in self._cache:
line = render_line(styles, y, size, base_background, background)
line = list(simplify(line))
self._cache[y] = line
else:
line = self._cache[y]
add_line(line)
self._dirty_lines.difference_update(region.line_range)
if region.column_span != (0, width):
_line_crop = line_crop
x1, x2 = region.column_span
lines = [_line_crop(line, x1, x2, width) for line in lines]
return lines
def render_content_line(self, y: int) -> list[Segment]:
return self._widget.render_line(y)
def render_line(
self,
styles: RenderStyles,
y: int,
size: Size,
base_background: Color,
background: Color,
) -> list[Segment]:
gutter = styles.gutter
width, height = size
pad_top, pad_right, pad_bottom, pad_left = styles.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_style = from_color(bgcolor=background.rich_color) + rich_style
outer_style = from_color(bgcolor=base_background.rich_color)
def post(segments: Iterable[Segment]) -> list[Segment]:
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
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_style,
outer_style,
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
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, _, _), _ = get_box(
border_left,
inner_style,
outer_style,
from_color(color=border_left_color.rich_color),
)
_, (_, _, right), _ = get_box(
border_right,
inner_style,
outer_style,
from_color(color=border_right_color.rich_color),
)
if border_left and border_right:
line = [left, Segment(" " * (width - 2), background_style), right]
if border_left:
line = [left, Segment(" " * (width - 1), background_style)]
if border_right:
line = [Segment(" " * (width - 1), background_style), right]
else:
line = [Segment(" " * width, background_style)]
else:
# Apply background style
line = self.render_content_line(y - gutter.top)
if inner_style:
line = Segment.apply_style(line, inner_style)
line = line_pad(line, pad_left, pad_right, inner_style)
if border_left or border_right:
# Add left / right border
_, (left, _, _), _ = get_box(
border_left,
inner_style,
outer_style,
from_color(border_left_color.rich_color),
)
_, (_, _, right), _ = get_box(
border_right,
inner_style,
outer_style,
from_color(border_right_color.rich_color),
)
if border_left and border_right:
line = [left, *line, right]
elif border_left:
line = [left, *line]
else:
line = [*line, right]
if (outline_top and y == 0) or (outline_bottom and y == height - 1):
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_style,
outer_style,
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:
_, (left, _, _), _ = get_box(
outline_left,
inner_style,
outer_style,
from_color(outline_left_color.rich_color),
)
_, (_, _, right), _ = get_box(
outline_right,
inner_style,
outer_style,
from_color(outline_right_color.rich_color),
)
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)
if __name__ == "__main__":
from rich import print
from .css.styles import Styles
styles = Styles()
styles.padding = 2
styles.border = (
("tall", Color.parse("red")),
("none", Color.parse("white")),
("outer", Color.parse("red")),
("none", Color.parse("red")),
)
size = Size(40, 10)
sr = StylesRenderer(None)
lines = sr._render(
size, size.region, styles, Color.parse("blue"), Color.parse("green")
)
for line in lines:
print(line)
from rich.segment import SegmentLines
print(SegmentLines(lines, new_lines=True))

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

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

View File

@@ -261,12 +261,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

@@ -140,7 +140,8 @@ class Size(NamedTuple):
return Region(0, 0, width, height)
@property
def lines_range(self) -> range:
def line_range(self) -> range:
"""Get a range covering lines."""
return range(self.height)
def __add__(self, other: object) -> Size:

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,
)
@@ -20,37 +20,32 @@ 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 ._styles_render import StylesRenderer
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,
)
@@ -120,7 +115,7 @@ class Widget(DOMNode):
self._arrangement: ArrangeResult | None = None
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
self._styles_renderer = StylesRenderer(self)
self._styles_cache = StylesCache()
super().__init__(name=name, id=id, classes=classes)
self.add_children(*children)
@@ -443,9 +438,14 @@ class Widget(DOMNode):
"""
if regions:
self._dirty_regions.update(regions)
widget_regions = [
region.translate(self.content_offset) for region in regions
]
self._dirty_regions.update(widget_regions)
self._styles_cache.set_dirty(*widget_regions)
else:
self._dirty_regions.clear()
self._styles_cache.clear()
# TODO: Does this need to be content region?
# self._dirty_regions.append(self.size.region)
self._dirty_regions.add(self.size.region)
@@ -668,7 +668,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)
@@ -847,7 +847,7 @@ class Widget(DOMNode):
@property
def content_size(self) -> Size:
return self._size - self.styles.gutter.totals
return self.content_region.size
@property
def size(self) -> Size:
@@ -860,12 +860,12 @@ class Widget(DOMNode):
@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)
return self.region.shrink(self.styles.gutter)
@property
def content_offset(self) -> Offset:
"""An offset from the Widget origin where the content begins."""
x, y = self.styles.content_gutter.top_left
x, y = self.styles.gutter.top_left
return Offset(x, y)
@property
@@ -998,15 +998,9 @@ class Widget(DOMNode):
Lines: A list of list of segments
"""
if self._dirty_regions:
self._styles_renderer.set_dirty(*self._dirty_regions)
self._render_lines()
lines = self._styles_renderer.render(crop)
return 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.content_size.width
try:
row_index, line_no = self._get_offsets(y)
@@ -496,38 +498,58 @@ 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.
Args:
crop (Region): Region to crop to.
Returns:
Lines: A list of segments for every line within crop region.
"""
scroll_y = self.scroll_offset.y
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
base_style = self.rich_style
def render_line(self, y: int) -> list[Segment]:
width, height = self.content_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:
lines = self._styles_cache.render_widget(self, crop)
return lines
# def render_lines(self, crop: Region) -> Lines:
# """Render lines within a given region.
# Args:
# crop (Region): Region to crop to.
# Returns:
# Lines: A list of segments for every line within crop region.
# """
# scroll_y = self.scroll_offset.y
# x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
# base_style = self.rich_style
# fixed_top_row_count = sum(
# self.get_row_height(row_index) for row_index in range(self.fixed_rows)
# )
# if self.show_header:
# fixed_top_row_count += self.get_row_height(-1)
# render_line = self._render_line
# fixed_lines = [
# render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count)
# ]
# lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)]
# for line_index, y in enumerate(range(y1, y2)):
# if y - scroll_y < fixed_top_row_count:
# lines[line_index] = fixed_lines[line_index]
# return lines
def on_mouse_move(self, event: events.MouseMove):
meta = event.style.meta
if meta:
@@ -551,6 +573,7 @@ class DataTable(ScrollView, Generic[CellType]):
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
region = self._get_cell_region(self.cursor_row, self.cursor_column)
region.translate(self.content_offset)
spacing = self._get_cell_border()
self.scroll_to_region(region, animate=animate, spacing=spacing)

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

@@ -2,7 +2,7 @@ from rich.segment import Segment
from rich.style import Style
from textual._segment_tools import line_crop, line_trim
from textual._segment_tools import line_crop, line_trim, line_pad
def test_line_crop():
@@ -89,3 +89,25 @@ def test_line_trim():
]
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

260
tests/test_styles_cache.py Normal file
View File

@@ -0,0 +1,260 @@
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__,
)
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__,
)
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__,
)
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__,
)
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__,
)
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__,
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,
)
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,
)
assert rendered_lines == [0, 1]
text_content = _extract_content(lines)
assert text_content == expected_text