diff --git a/old examples/easing.py b/old examples/easing.py index 447f69e5f..e70620e05 100644 --- a/old examples/easing.py +++ b/old examples/easing.py @@ -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 ) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 791d3ee14..0624e0846 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -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; } @@ -104,7 +107,7 @@ Tweet { .scrollable { - + overflow-y: scroll; margin: 1 2; height: 20; @@ -112,8 +115,7 @@ Tweet { layout: vertical; } -.code { - +.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; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 3e5f02732..98d186b41 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -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"): diff --git a/src/textual/_border.py b/src/textual/_border.py index 97a5ecd8b..99dcc0665 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -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 diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bb2523485..d1a93576b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -386,33 +386,23 @@ 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, - clip, - total_region.size, - container_size, - ) - else: - map[widget] = MapGeometry( - child_region + layout_offset, - order, - clip, - child_region.size, - container_size, - ) + map[widget] = MapGeometry( + region + layout_offset, + order, + clip, + total_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) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 75a235d8a..df9455f8e 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -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) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py new file mode 100644 index 000000000..dde15bf72 --- /dev/null +++ b/src/textual/_styles_cache.py @@ -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) diff --git a/src/textual/color.py b/src/textual/color.py index b7dcac440..2647ba80b 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -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. """ diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 541235e5a..e3ffd4141 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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) diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 3fa4d3a30..102e7eb42 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"} VALID_BORDER: Final[set[EdgeType]] = { "none", "hidden", + "ascii", "round", "blank", "solid", diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 58cc4252f..ae37249f9 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -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) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d2e0a042d..4d0ab347d 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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'.""" diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 1463bf6fa..206705276 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -14,6 +14,7 @@ else: Edge = Literal["top", "right", "bottom", "left"] EdgeType = Literal[ "", + "ascii", "none", "hidden", "blank", diff --git a/src/textual/geometry.py b/src/textual/geometry.py index a3d611a15..11ca38254 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 47c6788e7..4f6588048 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -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] diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 023995a6a..f8ed4f152 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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] diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py index f1d73d335..22c8b5513 100644 --- a/src/textual/renderables/opacity.py +++ b/src/textual/renderables/opacity.py @@ -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 -class Opacity: - """Wrap a renderable to blend foreground color into the background color. +@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: - renderable (RenderableType): The RenderableType to manipulate. - opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. + 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. + """ 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__": diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index 464bdd76d..196a07be4 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -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) diff --git a/src/textual/screen.py b/src/textual/screen.py index acf9552ff..7ad6d3e02 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index 6c5ad0130..d2bd56a5b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 31782bc8a..472c98bc1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -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): diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index 0d1319ae6..3ad0585cd 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -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 diff --git a/tests/test_border.py b/tests/test_border.py new file mode 100644 index 000000000..4e2d56390 --- /dev/null +++ b/tests/test_border.py @@ -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], + ] diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 241acdd34..eb9e9a16a 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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(): diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 2046a37b8..85990497f 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -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 diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index 15347dcd6..770f33e23 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -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 diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py new file mode 100644 index 000000000..ea892f189 --- /dev/null +++ b/tests/test_styles_cache.py @@ -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 diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index a327a54c9..8e422f2e7 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -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: