from __future__ import annotations from typing import cast, List, Sequence 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 pytestmark = pytest.mark.integration_test # Let's allow ourselves some abbreviated names for those tests, # in order to make the test cases a bit easier to read :-) SCREEN_W = 100 # width of our Screens SCREEN_H = 8 # height of our Screens SCREEN_SIZE = Size(SCREEN_W, SCREEN_H) PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets # As per Widget's CSS property, by default Widgets have a horizontal scrollbar of size 1 # and a vertical scrollbar of size 2: SCROLL_H_SIZE = 1 SCROLL_V_SIZE = 2 @pytest.mark.asyncio @pytest.mark.parametrize( ( "placeholders_count", "root_container_style", "placeholders_style", "expected_root_widget_virtual_size", "expected_placeholders_size", "expected_placeholders_offset_x", ), ( *[ [ 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") ], [ 1, "border: solid white;", # #root has a visible 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, ], [ 4, "border: solid white;", # #root has a visible border "", # no specific placeholder style # #root's virtual height should be as high as its stacked content (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4), # placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders (SCREEN_W - 2 - SCROLL_V_SIZE, PLACEHOLDERS_DEFAULT_H), # placeholders should be at offset 1 because of #root's border 1, ], [ 1, "border: solid white;", # #root has a visible border "align: center top;", # placeholders are centered horizontally # #root's virtual size=screen size (SCREEN_W, SCREEN_H), # placeholders width=same than screen, minus 2 borders :: height=default height (SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H), # placeholders should be at offset 1 because of #root's border 1, ], [ 4, "border: solid white;", # #root has a visible border "align: center top;", # placeholders are centered horizontally # #root's virtual height should be as high as its stacked content ( SCREEN_W - 2 - SCROLL_V_SIZE, PLACEHOLDERS_DEFAULT_H * 4, ), # placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height (SCREEN_W - 2 - SCROLL_V_SIZE, PLACEHOLDERS_DEFAULT_H), # placeholders should be at offset 1 because of #root's border 1, ], ), ) async def test_composition_of_vertical_container_with_children( placeholders_count: int, root_container_style: str, placeholders_style: str, expected_placeholders_size: tuple[int, int], expected_root_widget_virtual_size: tuple[int, int], expected_placeholders_offset_x: int, ): class VerticalContainer(Widget): DEFAULT_CSS = ( """ VerticalContainer { layout: vertical; overflow: hidden auto; ${root_container_style} } VerticalContainer Placeholder { height: ${placeholders_height}; ${placeholders_style} } """.replace( "${root_container_style}", root_container_style ) .replace("${placeholders_height}", str(PLACEHOLDERS_DEFAULT_H)) .replace("${placeholders_style}", placeholders_style) ) class MyTestApp(AppTest): def compose(self) -> ComposeResult: placeholders = [ Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}") for i in range(placeholders_count) ] yield VerticalContainer(*placeholders, id="root") app = MyTestApp(size=SCREEN_SIZE, test_name="compositor") expected_screen_size = SCREEN_SIZE async with app.in_running_state(): # root widget checks: root_widget = cast(Widget, app.get_child("root")) assert root_widget.outer_size == expected_screen_size root_widget_region = app.screen.find_widget(root_widget).region assert root_widget_region == ( 0, 0, expected_screen_size.width, expected_screen_size.height, ) app_placeholders = cast(List[Widget], app.query("Placeholder")) assert len(app_placeholders) == placeholders_count # placeholder widgets checks: for placeholder in app_placeholders: 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 @pytest.mark.asyncio @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", ] ], ), ) 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) -> 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.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 assert top_left_edge_color.upper() == expected_top_left_edge_color.upper() 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 @pytest.mark.asyncio @pytest.mark.parametrize( "large_widget_size,container_style,expected_large_widget_visible_region_size", ( # In these tests we're going to insert a "large widget" # into a container with size (20,20). # ---------------- let's start! # no overflow/scrollbar instructions: no scrollbars [Size(30, 30), "color: red", Size(20, 20)], # explicit hiding of the overflow: no scrollbars either [Size(30, 30), "overflow: hidden", Size(20, 20)], # scrollbar for both directions [ Size(30, 30), "overflow: auto", Size( 20 - SCROLL_V_SIZE, 20 - SCROLL_H_SIZE, ), ], # horizontal scrollbar [Size(30, 30), "overflow-x: auto", Size(20, 20 - SCROLL_H_SIZE)], # vertical scrollbar [Size(30, 30), "overflow-y: auto", Size(20 - SCROLL_V_SIZE, 20)], # scrollbar for both directions, custom scrollbar size [Size(30, 30), ("overflow: auto", "scrollbar-size: 3 5"), Size(20 - 5, 20 - 3)], # scrollbar for both directions, custom vertical scrollbar size [ Size(30, 30), ("overflow: auto", "scrollbar-size-vertical: 3"), Size(20 - 3, 20 - SCROLL_H_SIZE), ], # scrollbar for both directions, custom horizontal scrollbar size [ Size(30, 30), ("overflow: auto", "scrollbar-size-horizontal: 3"), Size(20 - SCROLL_V_SIZE, 20 - 3), ], # scrollbar needed only horizontally, custom scrollbar size [ Size(30, 20), ("overflow: auto", "scrollbar-size: 3 3"), Size(20, 20 - 3), ], ), ) async def test_scrollbar_size_impact_on_the_layout( large_widget_size: Size, container_style: str | Sequence[str], expected_large_widget_visible_region_size: Size, ): class LargeWidget(Widget): def on_mount(self): self.styles.width = large_widget_size[0] self.styles.height = large_widget_size[1] container_style_rules = ( [container_style] if isinstance(container_style, str) else container_style ) class LargeWidgetContainer(Widget): # TODO: Once textual#581 ("Default versus User CSS") is solved the following CSS should just use the # "LargeWidgetContainer" selector, without having to use a more specific one to be able to override Widget's CSS: DEFAULT_CSS = """ #large-widget-container { width: 20; height: 20; ${container_style}; } """.replace( "${container_style}", ";\n".join(container_style_rules), ) large_widget = LargeWidget() large_widget.expand = False container = LargeWidgetContainer(large_widget, id="large-widget-container") class MyTestApp(AppTest): def compose(self) -> ComposeResult: yield container app = MyTestApp(size=Size(40, 40), test_name="scrollbar_size_impact_on_the_layout") await app.boot_and_shutdown() compositor = app.screen._compositor widgets_map = compositor.map large_widget_visible_region_size = widgets_map[large_widget].visible_region.size assert large_widget_visible_region_size == expected_large_widget_visible_region_size