diff --git a/pyproject.toml b/pyproject.toml index a7855ae38..ce457926c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ includes = "src" [tool.pytest.ini_options] asyncio_mode = "auto" +testpaths = ["tests"] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/sandbox/color_names.py b/sandbox/color_names.py new file mode 100644 index 000000000..6e0d904e9 --- /dev/null +++ b/sandbox/color_names.py @@ -0,0 +1,46 @@ +import rich.repr +from rich.align import Align +from rich.console import RenderableType +from rich.panel import Panel +from rich.pretty import Pretty + +from textual._color_constants import COLOR_NAME_TO_RGB +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Placeholder + + +@rich.repr.auto(angular=False) +class ColorDisplay(Widget, can_focus=True): + def render(self) -> RenderableType: + return Panel( + Align.center( + Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle" + ), + title=self.name, + border_style="none", + ) + + +class ColorNames(App): + CSS = """ + ColorDisplay { + height: 1; + } + """ + + def on_mount(self): + self.bind("q", "quit") + + def compose(self) -> ComposeResult: + for color_name, color in COLOR_NAME_TO_RGB.items(): + color_placeholder = ColorDisplay(name=color_name) + is_dark_color = sum(color) < 400 + color_placeholder.styles.color = "white" if is_dark_color else "black" + color_placeholder.styles.background = color_name + yield color_placeholder + + +if __name__ == "__main__": + color_name_app = ColorNames() + color_name_app.run() diff --git a/src/textual/_border.py b/src/textual/_border.py index a25a69dfe..a7c8a72e8 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -15,6 +15,7 @@ INNER = 1 OUTER = 2 BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = { + # TODO: in "browsers' CSS" `none` and `hidden` both set the border width to zero. Should we do the same? "": (" ", " ", " "), "none": (" ", " ", " "), "hidden": (" ", " ", " "), diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5e4652a59..1b1b0ed0b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -15,7 +15,7 @@ from __future__ import annotations from operator import attrgetter, itemgetter import sys -from typing import cast, Callable, Iterator, Iterable, NamedTuple, TYPE_CHECKING +from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING import rich.repr from rich.console import Console, ConsoleOptions, RenderResult @@ -23,15 +23,13 @@ from rich.control import Control from rich.segment import Segment, SegmentLines from rich.style import Style -from . import errors, log +from . import errors from .geometry import Region, Offset, Size from ._loop import loop_last -from ._profile import timer from ._segment_tools import line_crop from ._types import Lines -from .widget import Widget if sys.version_info >= (3, 10): from typing import TypeAlias @@ -462,7 +460,6 @@ class Compositor: """Render a layout. Args: - console (Console): Console instance. clip (Optional[Region]): Region to clip to. Returns: diff --git a/src/textual/app.py b/src/textual/app.py index f335fe013..3142973e6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -20,6 +20,11 @@ from typing import ( TYPE_CHECKING, ) +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover + import rich import rich.repr from rich.console import Console, RenderableType @@ -108,6 +113,10 @@ class App(Generic[ReturnType], DOMNode): driver_class: Type[Driver] | None = None, log_path: str | PurePath = "", log_verbosity: int = 1, + # TODO: make this Literal a proper type in Rich, so we re-use it? + log_color_system: Literal[ + "auto", "standard", "256", "truecolor", "windows" + ] = "auto", title: str = "Textual Application", css_path: str | PurePath | None = None, watch_css: bool = False, @@ -155,6 +164,7 @@ class App(Generic[ReturnType], DOMNode): self._log_file = open(log_path, "wt") self._log_console = Console( file=self._log_file, + color_system=log_color_system, markup=False, emoji=False, highlight=False, @@ -484,6 +494,18 @@ class App(Generic[ReturnType], DOMNode): return DOMQuery(self.screen, selector) + def query_one(self, selector: str) -> DOMNode | None: + """Retrieves a single node via a DOM query in the current screen. + + Args: + selector (str): A CSS selector . + + Returns: + DOMNode | None: The first node matching the query, or None if no node matches the selector. + """ + result = self.query(selector) + return result.first() if len(result) else None + def get_child(self, id: str) -> DOMNode: """Shorthand for self.screen.get_child(id: str) Returns the first child (immediate descendent) of this DOMNode diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 194ed1f2b..5e0d97175 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -120,9 +120,7 @@ class LinuxDriver(Driver): self.console.show_cursor(False) self.console.file.write("\033[?1003h\n") self.console.file.flush() - self._key_thread = Thread( - target=self.run_input_thread, args=(asyncio.get_running_loop(),) - ) + self._key_thread = Thread(target=self.run_input_thread, args=(loop,)) send_size_event() self._key_thread.start() diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 67834ed49..cd01c0668 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import cast, TYPE_CHECKING -from .. import log - from ..geometry import Offset, Region, Size from .._layout import Layout, WidgetPlacement @@ -23,7 +21,7 @@ class VerticalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - y = max_width = max_height = 0 + max_width = max_height = 0 parent_size = parent.size box_models = [ @@ -40,14 +38,11 @@ class VerticalLayout(Layout): y = box_models[0].margin.top if box_models else 0 - displayed_children = parent.displayed_children - + displayed_children = cast("list[Widget]", parent.displayed_children) for widget, box_model, margin in zip(displayed_children, box_models, margins): content_width, content_height = box_model.size offset_x = widget.styles.align_width(content_width, parent_size.width) region = Region(offset_x, y, content_width, content_height) - # TODO: it seems that `max_height` is not used? - max_height = max(max_height, content_height) add_placement(WidgetPlacement(region, widget, 0)) y += region.height + margin max_height = y diff --git a/src/textual/screen.py b/src/textual/screen.py index 75607ab44..a575018da 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -20,8 +20,8 @@ class Screen(Widget): CSS = """ Screen { - layout: dock; - docks: _default=top; + layout: vertical; + overflow-y: auto; background: $surface; color: $text-surface; } diff --git a/src/textual/widget.py b/src/textual/widget.py index 9ce3b74f4..d46485df6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -28,6 +28,7 @@ from ._context import active_app from ._types import Lines from .dom import DOMNode from .geometry import clamp, Offset, Region, Size +from .layouts.vertical import VerticalLayout from .message import Message from . import messages from ._layout import Layout @@ -82,6 +83,7 @@ class Widget(DOMNode): self._virtual_size = Size(0, 0) self._container_size = Size(0, 0) self._layout_required = False + self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self._reactive_watches: dict[str, Callable] = {} self.highlight_style: Style | None = None @@ -401,6 +403,7 @@ class Widget(DOMNode): Returns: Region: The widget region minus scrollbars. """ + # return region show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled if show_horizontal_scrollbar and show_vertical_scrollbar: (region, _, _, _) = region.split(-1, -1) @@ -553,7 +556,12 @@ class Widget(DOMNode): @property def layout(self) -> Layout | None: - return self.styles.layout + return self.styles.layout or ( + # If we have children we _should_ return a layout, otherwise they won't be displayed: + self._default_layout + if self.children + else None + ) @property def is_container(self) -> bool: diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py new file mode 100644 index 000000000..b8ebfd63b --- /dev/null +++ b/tests/test_integration_layout.py @@ -0,0 +1,148 @@ +from __future__ import annotations +import asyncio +from typing import cast, List + +import pytest + +from tests.utilities.test_app import AppTest +from textual.app import ComposeResult +from textual.geometry import Size +from textual.widget import Widget +from textual.widgets import Placeholder + +# 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 + + +@pytest.mark.asyncio +@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts +@pytest.mark.parametrize( + ( + "screen_size", + "placeholders_count", + "root_container_style", + "placeholders_style", + "expected_placeholders_size", + "expected_root_widget_virtual_size", + ), + ( + [ + SCREEN_SIZE, + 1, + "border: ;", # #root has no border + "", # no specific placeholder style + # placeholders width=same than screen :: height=default height + (SCREEN_W, PLACEHOLDERS_DEFAULT_H), + # same for #root's virtual size + (SCREEN_W, SCREEN_H), + ], + [ + # "none" borders still allocate a space for the (invisible) border + SCREEN_SIZE, + 1, + "border: none;", # #root has an invisible border + "", # no specific placeholder style + # placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders + (SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H), + # same for #root's virtual size + (SCREEN_W - 2, SCREEN_H - 2), + ], + [ + SCREEN_SIZE, + 1, + "border: solid white;", # #root has a visible border + "", # no specific placeholder style + # placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders + (SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H), + # same for #root's virtual size + (SCREEN_W - 2, SCREEN_H - 2), + ], + [ + SCREEN_SIZE, + 4, + "border: solid white;", # #root has a visible border + "", # no specific placeholder style + # placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders + (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H), + # #root's virtual height should be as high as its stacked content + (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4), + ], + # TODO: fix the bug that messes up the layout when the placeholders have "align: center top;": + # [ + # SCREEN_SIZE, + # 4, + # "border: solid white;", # #root has a visible border + # "align: center top;", + # # placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders + # (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H), + # # #root's virtual height should be as high as its stacked content + # (SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4), + # ], + ), +) +async def test_vertical_container_with_children( + screen_size: Size, + placeholders_count: int, + root_container_style: str, + placeholders_style: str, + expected_placeholders_size: tuple[int, int], + expected_root_widget_virtual_size: tuple[int, int], + event_loop: asyncio.AbstractEventLoop, +): + class VerticalContainer(Widget): + 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") + + async with app.in_running_state(): + app.log_tree() + + root_widget = cast(Widget, app.query_one("#root")) + assert root_widget.size == screen_size + assert root_widget.virtual_size == expected_root_widget_virtual_size + root_widget_region = app.screen.get_widget_region(root_widget) + assert root_widget_region == (0, 0, screen_size.width, screen_size.height) + + app_placeholders = cast(List[Widget], app.query("Placeholder")) + assert len(app_placeholders) == placeholders_count + + for placeholder in app_placeholders: + assert placeholder.size == expected_placeholders_size + # expected_placeholder_size = ( + # test_size.width - 2, # because of the 2 horizontal borders of "#root" + # test_size.height - 2, # ditto with vertical borders + # ) + # assert placeholder.size == expected_root_widget_size + # assert placeholder.virtual_size == expected_root_widget_virtual_size + assert placeholder.styles.offset.x.value == 0.0 + # assert app.screen.get_offset(placeholder).x == 1 diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py new file mode 100644 index 000000000..45ebfb31a --- /dev/null +++ b/tests/utilities/test_app.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +import contextlib +import io +from pathlib import Path +from typing import AsyncContextManager + +from rich.console import Console, Capture +from textual import events +from textual.app import App, ReturnType, ComposeResult +from textual.driver import Driver +from textual.geometry import Size + + +# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc, +# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/ + + +class AppTest(App): + def __init__( + self, + *, + test_name: str, + size: Size, + log_verbosity: int = 2, + ): + # will log in "/tests/test.[test name].log": + log_path = Path(__file__).parent.parent / f"test.{test_name}.log" + super().__init__( + driver_class=DriverTest, + log_path=log_path, + log_verbosity=log_verbosity, + log_color_system="256", + ) + self._size = size + self._console = ConsoleTest(width=size.width, height=size.height) + self._error_console = ConsoleTest(width=size.width, height=size.height) + + def log_tree(self) -> None: + """Handy shortcut when testing stuff""" + self.log(self.tree) + + def compose(self) -> ComposeResult: + raise NotImplementedError( + "Create a subclass of TestApp and override its `compose()` method, rather than using TestApp directly" + ) + + def in_running_state( + self, + *, + initialisation_timeout: float = 0.1, + ) -> AsyncContextManager[Capture]: + async def run_app() -> None: + await self.process_messages() + + @contextlib.asynccontextmanager + async def get_running_state_context_manager(): + run_task = asyncio.create_task(run_app()) + timeout_before_yielding_task = asyncio.create_task( + asyncio.sleep(initialisation_timeout) + ) + done, pending = await asyncio.wait( + ( + run_task, + timeout_before_yielding_task, + ), + return_when=asyncio.FIRST_COMPLETED, + ) + if run_task in done or run_task not in pending: + raise RuntimeError( + "TestApp is no longer return after its initialization period" + ) + with self.console.capture() as capture: + yield capture + assert not run_task.done() + await self.shutdown() + + return get_running_state_context_manager() + + def run(self): + raise NotImplementedError( + "Use `async with my_test_app.get_running_state()` rather than `my_test_app.run()`" + ) + + @property + def console(self) -> ConsoleTest: + return self._console + + @console.setter + def console(self, console: Console) -> None: + """This is a no-op, the console is always a TestConsole""" + return + + @property + def error_console(self) -> ConsoleTest: + return self._error_console + + @error_console.setter + def error_console(self, console: Console) -> None: + """This is a no-op, the error console is always a TestConsole""" + return + + +class ConsoleTest(Console): + def __init__(self, *, width: int, height: int): + file = io.StringIO() + super().__init__( + color_system="256", + file=file, + width=width, + height=height, + force_terminal=True, + ) + + +class DriverTest(Driver): + def start_application_mode(self) -> None: + size = Size(self.console.size.width, self.console.size.height) + event = events.Resize(self._target, size, size) + asyncio.run_coroutine_threadsafe( + self._target.post_message(event), + loop=asyncio.get_running_loop(), + ) + + def disable_input(self) -> None: + pass + + def stop_application_mode(self) -> None: + pass