From af2f1580ce660b95f2c345f0f8271957f1bfe07f Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 18 May 2022 11:40:53 +0100 Subject: [PATCH] [css] Add a test for the impact of our border edge types on the layout --- sandbox/borders.py | 30 +++---- src/textual/_border.py | 18 ++--- src/textual/css/_style_properties.py | 9 ++- src/textual/css/_styles_builder.py | 4 +- tests/test_integration_layout.py | 113 +++++++++++++++++++++------ tests/utilities/test_app.py | 32 +++++++- 6 files changed, 152 insertions(+), 54 deletions(-) diff --git a/sandbox/borders.py b/sandbox/borders.py index 7075baf18..9a2b7e2d6 100644 --- a/sandbox/borders.py +++ b/sandbox/borders.py @@ -17,7 +17,7 @@ class VerticalContainer(Widget): VerticalContainer Placeholder { margin: 1 0; - height: 5; + height: auto; align: center top; } """ @@ -37,26 +37,28 @@ class Introduction(Widget): return Text("Here are the color edge types we support.", justify="center") +class BorderDemo(Widget): + def __init__(self, name: str): + super().__init__(name=name) + + def render(self, style) -> RenderableType: + return Text(self.name, style="black on yellow", justify="center") + + class MyTestApp(App): def compose(self) -> ComposeResult: - placeholders = [] + border_demo_widgets = [] for border_edge_type in EdgeType.__args__: - border_placeholder = Placeholder( - id=f"placeholder_{border_edge_type}", - title=(border_edge_type or " ").upper(), - name=f"border: {border_edge_type} white", - ) - border_placeholder.styles.border = (border_edge_type, "white") - placeholders.append(border_placeholder) + border_demo = BorderDemo(f'"border: {border_edge_type} white"') + border_demo.styles.height = "auto" + border_demo.styles.margin = (1, 0) + border_demo.styles.border = (border_edge_type, "white") + border_demo_widgets.append(border_demo) - yield VerticalContainer(Introduction(), *placeholders, id="root") + yield VerticalContainer(Introduction(), *border_demo_widgets, id="root") def on_mount(self): self.bind("q", "quit") - self.bind("t", "tree") - - def action_tree(self): - self.log(self.tree) app = MyTestApp() diff --git a/src/textual/_border.py b/src/textual/_border.py index aa9c91a76..a81b4b83f 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -57,7 +57,7 @@ BORDER_LOCATIONS: dict[ "wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)), } -_INVISIBLE_EDGE_TYPES: tuple[EdgeType, ...] = ("none", "hidden") +INVISIBLE_EDGE_TYPES: tuple[EdgeType, ...] = ("", "none", "hidden") @lru_cache(maxsize=1024) @@ -170,10 +170,10 @@ class Border: width (int): Desired width. """ top, right, bottom, left = self._sides - has_left = left not in _INVISIBLE_EDGE_TYPES - has_right = right not in _INVISIBLE_EDGE_TYPES - has_top = top not in _INVISIBLE_EDGE_TYPES - has_bottom = bottom not in _INVISIBLE_EDGE_TYPES + has_left = left not in INVISIBLE_EDGE_TYPES + has_right = right not in INVISIBLE_EDGE_TYPES + has_top = top not in INVISIBLE_EDGE_TYPES + has_bottom = bottom not in INVISIBLE_EDGE_TYPES if has_top: lines.pop(0) @@ -199,10 +199,10 @@ class Border: outer_style = console.get_style(self.outer_style) top_style, right_style, bottom_style, left_style = self._styles - has_left = left not in _INVISIBLE_EDGE_TYPES - has_right = right not in _INVISIBLE_EDGE_TYPES - has_top = top not in _INVISIBLE_EDGE_TYPES - has_bottom = bottom not in _INVISIBLE_EDGE_TYPES + has_left = left not in INVISIBLE_EDGE_TYPES + has_right = right not in INVISIBLE_EDGE_TYPES + has_top = top not in INVISIBLE_EDGE_TYPES + has_bottom = bottom not in INVISIBLE_EDGE_TYPES width = options.max_width - has_left - has_right diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index b7a161c71..6d0a4ac83 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -27,6 +27,7 @@ from ._help_text import ( string_enum_help_text, color_property_help_text, ) +from .._border import INVISIBLE_EDGE_TYPES from ..color import Color, ColorPair, ColorParseError from ._error_tools import friendly_list from .constants import NULL_SPACING, VALID_STYLE_FLAGS @@ -243,10 +244,10 @@ class Edges(NamedTuple): """ (top, _), (right, _), (bottom, _), (left, _) = self return Spacing( - 1 if top else 0, - 1 if right else 0, - 1 if bottom else 0, - 1 if left else 0, + 1 if top not in INVISIBLE_EDGE_TYPES else 0, + 1 if right not in INVISIBLE_EDGE_TYPES else 0, + 1 if bottom not in INVISIBLE_EDGE_TYPES else 0, + 1 if left not in INVISIBLE_EDGE_TYPES else 0, ) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index a81df3d81..d84a83b08 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -42,7 +42,7 @@ from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError from .styles import DockGroup, Styles from .tokenize import Token from .transition import Transition -from .types import BoxSizing, Edge, Display, Overflow, Visibility +from .types import BoxSizing, Edge, Display, Overflow, Visibility, EdgeType from ..color import Color, ColorParseError from .._duration import _duration_as_seconds from .._easing import EASING @@ -418,7 +418,7 @@ class StylesBuilder: process_padding_left = _process_space_partial def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]: - border_type = "solid" + border_type: EdgeType = "solid" border_color = Color(0, 255, 0) def border_value_error(): diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index c41b22cb6..4dc5d01c0 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -3,9 +3,12 @@ import asyncio from typing import cast, List import pytest +from rich.console import RenderableType +from rich.text import Text from tests.utilities.test_app import AppTest from textual.app import ComposeResult +from textual.css.types import EdgeType from textual.geometry import Size from textual.widget import Widget from textual.widgets import Placeholder @@ -31,30 +34,20 @@ PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets "expected_placeholders_offset_x", ), ( - [ - SCREEN_SIZE, - 1, - "border: ;", # #root has no border - "", # no specific placeholder style - # #root's virtual size=screen size - (SCREEN_W, SCREEN_H), - # placeholders width=same than screen :: height=default height - (SCREEN_W, PLACEHOLDERS_DEFAULT_H), - # placeholders should be at offset 0 - 0, - ], - [ - # "none" borders still allocate a space for the (invisible) border - SCREEN_SIZE, - 1, - "border: none;", # #root has an invisible border - "", # no specific placeholder style - # #root's virtual size is smaller because of its borders - (SCREEN_W - 2, SCREEN_H - 2), - # placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders - (SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H), - # placeholders should be at offset 1 because of #root's border - 1, + *[ + [ + SCREEN_SIZE, + 1, + f"border: {invisible_border_edge};", # #root has no visible border + "", # no specific placeholder style + # #root's virtual size=screen size + (SCREEN_W, SCREEN_H), + # placeholders width=same than screen :: height=default height + (SCREEN_W, PLACEHOLDERS_DEFAULT_H), + # placeholders should be at offset 0 + 0, + ] + for invisible_border_edge in ("", "none", "hidden") ], [ SCREEN_SIZE, @@ -169,3 +162,75 @@ async def test_composition_of_vertical_container_with_children( assert placeholder.size == expected_placeholders_size assert placeholder.styles.offset.x.value == 0.0 assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x + + +@pytest.mark.asyncio +@pytest.mark.integration_test +@pytest.mark.parametrize( + "edge_type,expected_box_inner_size,expected_box_size,expected_top_left_edge_color,expects_visible_char_at_top_left_edge", + ( + # These first 3 types of border edge types are synonyms, and display no borders: + ["", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False], + ["none", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False], + ["hidden", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False], + # Let's transition to "blank": we still see no visible border, but the size is increased + # as the gutter space is reserved the same way it would be with a border: + ["blank", Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", False], + # And now for the "normally visible" border edge types: + # --> we see a visible border, and the size is increased: + *[ + [edge_style, Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", True] + for edge_style in [ + "round", + "solid", + "double", + "dashed", + "heavy", + "inner", + "outer", + "hkey", + "vkey", + "tall", + "wide", + ] + ], + ), +) +async def test_border_edge_types_impact_on_widget_size( + edge_type: EdgeType, + expected_box_inner_size: Size, + expected_box_size: Size, + expected_top_left_edge_color: str, + expects_visible_char_at_top_left_edge: bool, +): + class BorderTarget(Widget): + def render(self, style) -> RenderableType: + return Text("border target", style="black on yellow", justify="center") + + border_target = BorderTarget() + border_target.styles.height = "auto" + border_target.styles.border = (edge_type, "white") + + class MyTestApp(AppTest): + def compose(self) -> ComposeResult: + yield border_target + + app = MyTestApp(size=SCREEN_SIZE, test_name="border_edge_types") + + await app.boot_and_shutdown() + + box_inner_size = Size( + border_target.content_region.width, + border_target.content_region.height, + ) + assert box_inner_size == expected_box_inner_size + + assert border_target.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 + assert top_left_edge_color == expected_top_left_edge_color + + top_left_edge_char = app.get_char_at(0, 0) + top_left_edge_char_is_a_visible_one = top_left_edge_char != " " + assert top_left_edge_char_is_a_visible_one == expects_visible_char_at_top_left_edge diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index c73a1e882..802578842 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -8,7 +8,7 @@ from typing import AsyncContextManager, cast from rich.console import Console -from textual import events +from textual import events, errors from textual.app import App, ReturnType, ComposeResult from textual.driver import Driver from textual.geometry import Size @@ -123,6 +123,36 @@ class AppTest(App): last_display_start_index = total_capture.rindex(CLEAR_SCREEN_SEQUENCE) return total_capture[last_display_start_index:] + def get_char_at(self, x: int, y: int) -> str: + """Get the character at the given cell or empty string + + Args: + x (int): X position within the Layout + y (int): Y position within the Layout + + Returns: + str: The character at the cell (x, y) within the Layout + """ + # N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()` + try: + widget, region = self.get_widget_at(x, y) + except errors.NoWidget: + return "" + if widget not in self.screen._compositor.regions: + return "" + + x -= region.x + y -= region.y + lines = widget.get_render_lines(y, y + 1) + if not lines: + return "" + end = 0 + for segment in lines[0]: + end += segment.cell_length + if x < end: + return segment.text[0] + return "" + @property def console(self) -> ConsoleTest: return self._console