From ac3d756e5127cb51a7fb11014b84b20c82217122 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 27 Oct 2022 17:43:02 +0100 Subject: [PATCH 01/20] unmount event --- CHANGELOG.md | 4 + sandbox/will/pride.py | 30 ++ src/textual/app.py | 368 +++++++++++++++++++------ src/textual/events.py | 4 + src/textual/message_pump.py | 12 +- src/textual/messages.py | 10 + src/textual/widget.py | 3 + tests/css/test_styles.py | 87 ------ tests/snapshot_tests/test_snapshots.py | 22 +- tests/test_integration_scrolling.py | 116 -------- tests/test_screens.py | 2 +- tests/utilities/test_app.py | 353 ------------------------ 12 files changed, 360 insertions(+), 651 deletions(-) create mode 100644 sandbox/will/pride.py delete mode 100644 tests/test_integration_scrolling.py delete mode 100644 tests/utilities/test_app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4f09490..b7d55e99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error +### Added + +- Added App.run_async method + ## [0.2.1] - 2022-10-23 ### Changed diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py new file mode 100644 index 000000000..a51f474bb --- /dev/null +++ b/sandbox/will/pride.py @@ -0,0 +1,30 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class PrideApp(App): + """Displays a pride flag.""" + + COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] + + def compose(self) -> ComposeResult: + for color in self.COLORS: + stripe = Static() + stripe.styles.height = "1fr" + stripe.styles.background = color + yield stripe + + +if __name__ == "__main__": + app = PrideApp() + + from rich import print + + async def run_app(): + async with app.run_async(quit_after=5) as result: + print(result) + print(app.tree) + + import asyncio + + asyncio.run(run_app()) diff --git a/src/textual/app.py b/src/textual/app.py index 43f1bd56c..0a82bf700 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager import inspect import io import os @@ -241,6 +242,11 @@ class App(Generic[ReturnType], DOMNode): ) self._screenshot: str | None = None + @property + def return_value(self) -> ReturnType | None: + """Get the return type.""" + return self._return_value + def animate( self, attribute: str, @@ -314,7 +320,7 @@ class App(Generic[ReturnType], DOMNode): result (ReturnType | None, optional): Return value. Defaults to None. """ self._return_value = result - self._close_messages_no_wait() + self.post_message_no_wait(messages.ExitApp(sender=self)) @property def focused(self) -> Widget | None: @@ -566,6 +572,95 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) + @classmethod + async def _press_keys(cls, app: App, press: Iterable[str]) -> None: + """A task to send key events.""" + assert press + driver = app._driver + assert driver is not None + await asyncio.sleep(0.02) + for key in press: + if key == "_": + print("(pause 50ms)") + await asyncio.sleep(0.05) + elif key.startswith("wait:"): + _, wait_ms = key.split(":") + print(f"(pause {wait_ms}ms)") + await asyncio.sleep(float(wait_ms) / 1000) + else: + if len(key) == 1 and not key.isalnum(): + key = ( + unicodedata.name(key) + .lower() + .replace("-", "_") + .replace(" ", "_") + ) + original_key = REPLACED_KEYS.get(key, key) + char: str | None + try: + char = unicodedata.lookup(original_key.upper().replace("_", " ")) + except KeyError: + char = key if len(key) == 1 else None + print(f"press {key!r} (char={char!r})") + key_event = events.Key(app, key, char) + driver.send_event(key_event) + # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race + # condition between widget-level key handling and app/screen level handling. + # More information here: https://github.com/Textualize/textual/issues/1009 + # This conditional sleep can be removed after that issue is closed. + if key == "tab": + await asyncio.sleep(0.05) + await asyncio.sleep(0.02) + await app._animator.wait_for_idle() + print("EXITING") + app.exit() + + @asynccontextmanager + async def run_async( + self, + *, + headless: bool = False, + quit_after: float | None = None, + press: Iterable[str] | None = None, + ): + """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. + + Example: + async def run_app(): + app = MyApp() + async with app.run_async() as result: + print(result) + + Args: + quit_after (float | None, optional): Quit after a given number of seconds, or None + to run forever. Defaults to None. + headless (bool, optional): Run in "headless" mode (don't write to stdout). + press (str, optional): An iterable of keys to simulate being pressed. + + """ + app = self + if headless: + app.features = cast( + "frozenset[FeatureFlag]", app.features.union({"headless"}) + ) + + if quit_after is not None: + app.set_timer(quit_after, app.exit) + + if press is not None: + + async def press_keys_task(): + asyncio.create_task(self._press_keys(app, press)) + + await app._process_messages(ready_callback=press_keys_task) + + else: + await app._process_messages() + + yield app.return_value + + await app._shutdown() + def run( self, *, @@ -589,72 +684,12 @@ class App(Generic[ReturnType], DOMNode): ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. """ - if headless: - self.features = cast( - "frozenset[FeatureFlag]", self.features.union({"headless"}) - ) - async def run_app() -> None: - if quit_after is not None: - self.set_timer(quit_after, self.shutdown) - if press is not None: - app = self - - async def press_keys() -> None: - """A task to send key events.""" - assert press - driver = app._driver - assert driver is not None - await asyncio.sleep(0.02) - for key in press: - if key == "_": - print("(pause 50ms)") - await asyncio.sleep(0.05) - elif key.startswith("wait:"): - _, wait_ms = key.split(":") - print(f"(pause {wait_ms}ms)") - await asyncio.sleep(float(wait_ms) / 1000) - else: - if len(key) == 1 and not key.isalnum(): - key = ( - unicodedata.name(key) - .lower() - .replace("-", "_") - .replace(" ", "_") - ) - original_key = REPLACED_KEYS.get(key, key) - try: - char = unicodedata.lookup( - original_key.upper().replace("_", " ") - ) - except KeyError: - char = key if len(key) == 1 else None - print(f"press {key!r} (char={char!r})") - key_event = events.Key(self, key, char) - driver.send_event(key_event) - # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race - # condition between widget-level key handling and app/screen level handling. - # More information here: https://github.com/Textualize/textual/issues/1009 - # This conditional sleep can be removed after that issue is closed. - if key == "tab": - await asyncio.sleep(0.05) - await asyncio.sleep(0.02) - - await app._animator.wait_for_idle() - - if screenshot: - self._screenshot = self.export_screenshot( - title=screenshot_title - ) - await self.shutdown() - - async def press_keys_task(): - """Press some keys in the background.""" - asyncio.create_task(press_keys()) - - await self._process_messages(ready_callback=press_keys_task) - else: - await self._process_messages() + async with self.run_async( + quit_after=quit_after, headless=headless, press=press + ): + if screenshot: + self._screenshot = self.export_screenshot(title=screenshot_title) if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: @@ -663,8 +698,107 @@ class App(Generic[ReturnType], DOMNode): # However, this works with Python<3.10: event_loop = asyncio.get_event_loop() event_loop.run_until_complete(run_app()) + return self.return_value - return self._return_value + # def run( + # self, + # *, + # quit_after: float | None = None, + # headless: bool = False, + # press: Iterable[str] | None = None, + # screenshot: bool = False, + # screenshot_title: str | None = None, + # ) -> ReturnType | None: + # """The main entry point for apps. + + # Args: + # quit_after (float | None, optional): Quit after a given number of seconds, or None + # to run forever. Defaults to None. + # headless (bool, optional): Run in "headless" mode (don't write to stdout). + # press (str, optional): An iterable of keys to simulate being pressed. + # screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. + # screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. + + # Returns: + # ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. + # """ + + # if headless: + # self.features = cast( + # "frozenset[FeatureFlag]", self.features.union({"headless"}) + # ) + + # async def run_app() -> None: + # if quit_after is not None: + # self.set_timer(quit_after, self.shutdown) + # if press is not None: + # app = self + + # async def press_keys() -> None: + # """A task to send key events.""" + # assert press + # driver = app._driver + # assert driver is not None + # await asyncio.sleep(0.02) + # for key in press: + # if key == "_": + # print("(pause 50ms)") + # await asyncio.sleep(0.05) + # elif key.startswith("wait:"): + # _, wait_ms = key.split(":") + # print(f"(pause {wait_ms}ms)") + # await asyncio.sleep(float(wait_ms) / 1000) + # else: + # if len(key) == 1 and not key.isalnum(): + # key = ( + # unicodedata.name(key) + # .lower() + # .replace("-", "_") + # .replace(" ", "_") + # ) + # original_key = REPLACED_KEYS.get(key, key) + # try: + # char = unicodedata.lookup( + # original_key.upper().replace("_", " ") + # ) + # except KeyError: + # char = key if len(key) == 1 else None + # print(f"press {key!r} (char={char!r})") + # key_event = events.Key(self, key, char) + # driver.send_event(key_event) + # # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race + # # condition between widget-level key handling and app/screen level handling. + # # More information here: https://github.com/Textualize/textual/issues/1009 + # # This conditional sleep can be removed after that issue is closed. + # if key == "tab": + # await asyncio.sleep(0.05) + # await asyncio.sleep(0.02) + + # await app._animator.wait_for_idle() + + # if screenshot: + # self._screenshot = self.export_screenshot( + # title=screenshot_title + # ) + # await self.shutdown() + + # async def press_keys_task(): + # """Press some keys in the background.""" + # asyncio.create_task(press_keys()) + + # await self._process_messages(ready_callback=press_keys_task) + # else: + # await self._process_messages() + + # if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: + # # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: + # asyncio.run(run_app()) + # else: + # # However, this works with Python<3.10: + # event_loop = asyncio.get_event_loop() + # event_loop.run_until_complete(run_app()) + + # return self._return_value async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" @@ -1037,7 +1171,7 @@ class App(Generic[ReturnType], DOMNode): self.log.system("[b green]STARTED[/]", self.css_monitor) async def run_process_messages(): - + """The main message look, invoke below.""" try: await self._dispatch_message(events.Compose(sender=self)) await self._dispatch_message(events.Mount(sender=self)) @@ -1066,7 +1200,6 @@ class App(Generic[ReturnType], DOMNode): await timer.stop() await self.animator.stop() - await self._close_all() self._running = True try: @@ -1104,11 +1237,11 @@ class App(Generic[ReturnType], DOMNode): driver.stop_application_mode() except Exception as error: self._handle_exception(error) - finally: - self._running = False - self._print_error_renderables() - if self.devtools is not None and self.devtools.is_connected: - await self._disconnect_devtools() + # finally: + # self._running = False + # self._print_error_renderables() + # if self.devtools is not None and self.devtools.is_connected: + # await self._disconnect_devtools() async def _pre_process(self) -> None: pass @@ -1133,7 +1266,7 @@ class App(Generic[ReturnType], DOMNode): """Used by docs plugin.""" svg = self.export_screenshot(title=screenshot_title) self._screenshot = svg # type: ignore - await self.shutdown() + self.exit() self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") @@ -1232,17 +1365,40 @@ class App(Generic[ReturnType], DOMNode): return widget in self._registry async def _close_all(self) -> None: - while self._registry: - child = self._registry.pop() + """Close all message pumps.""" + + # Close all screens on the stack + for screen in self._screen_stack: + await self._prune_node(screen) + + # Close pre-defined screens + for screen in self.SCREENS.values(): + if screen._running: + await self._prune_node(screen) + + # Close any remaining nodes + # Should be empty by now + remaining_nodes = list(self._registry) + for child in remaining_nodes: await child._close_messages() - async def shutdown(self): - await self._disconnect_devtools() + async def _shutdown(self) -> None: driver = self._driver if driver is not None: driver.disable_input() + await self._close_all() await self._close_messages() + await self._dispatch_message(events.UnMount(sender=self)) + + self._running = False + self._print_error_renderables() + if self.devtools is not None and self.devtools.is_connected: + await self._disconnect_devtools() + + async def _on_exit_app(self) -> None: + await self._message_queue.put(None) + def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: if self._screen_stack: self.screen.refresh(repaint=repaint, layout=layout) @@ -1496,18 +1652,64 @@ class App(Generic[ReturnType], DOMNode): [to_remove for to_remove in remove_widgets if to_remove.can_focus], ) - for child in remove_widgets: - await child._close_messages() - self._unregister(child) + await self._prune_node(widget) + + # for child in remove_widgets: + # await child._close_messages() + # self._unregister(child) if parent is not None: parent.refresh(layout=True) + def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: + """Walk children depth first, generating widgets and a list of their siblings. + + Returns: + Iterable[list[Widget]]: + + """ + stack: list[Widget] = [root] + pop = stack.pop + push = stack.append + + while stack: + widget = pop() + if widget.children: + yield list(widget.children) + for child in widget.children: + push(child) + + async def _prune_node(self, root: Widget) -> None: + """Remove a node and its children. Children are removed before parents. + + Args: + root (Widget): Node to remove. + """ + # Pruning a node that has been removed is a no-op + if root not in self._registry: + return + + node_children = list(self._walk_children(root)) + + for children in reversed(node_children): + # Closing children can be done asynchronously. + close_messages = [ + child._close_messages() for child in children if child._running + ] + # TODO: What if a message pump refuses to exit? + if close_messages: + await asyncio.gather(*close_messages) + for child in children: + self._unregister(child) + + await root._close_messages() + self._unregister(root) + async def action_check_bindings(self, key: str) -> None: await self.check_bindings(key) async def action_quit(self) -> None: """Quit the app as soon as possible.""" - await self.shutdown() + self.exit() async def action_bang(self) -> None: 1 / 0 diff --git a/src/textual/events.py b/src/textual/events.py index ee84b929f..f5742ac03 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -123,6 +123,10 @@ class Mount(Event, bubble=False, verbose=True): """Sent when a widget is *mounted* and may receive messages.""" +class UnMount(Mount, bubble=False, verbose=False): + """Sent when a widget is unmounted and may not longer receive messages.""" + + class Remove(Event, bubble=False): """Sent to a widget to ask it to remove itself from the DOM.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7d7712a81..2102602e1 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -267,7 +267,10 @@ class MessagePump(metaclass=MessagePumpMeta): def _close_messages_no_wait(self) -> None: """Request the message queue to exit.""" - self._message_queue.put_nowait(None) + self._message_queue.put_nowait(messages.CloseMessages(sender=self)) + + async def _on_close_messages(self, message: messages.CloseMessages) -> None: + await self._close_messages() async def _close_messages(self) -> None: """Close message queue, and optionally wait for queue to finish processing.""" @@ -278,6 +281,7 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in stop_timers: await timer.stop() self._timers.clear() + await self._message_queue.put(events.UnMount(sender=self)) await self._message_queue.put(None) if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning @@ -370,7 +374,7 @@ class MessagePump(metaclass=MessagePumpMeta): self.app._handle_exception(error) break - log("CLOSED", self) + # log("CLOSED", self) async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. @@ -424,6 +428,7 @@ class MessagePump(metaclass=MessagePumpMeta): handler_name = message._handler_name # Look through the MRO to find a handler + dispatched = False for cls, method in self._get_dispatch_methods(handler_name, message): log.event.verbosity(message.verbose)( message, @@ -431,7 +436,10 @@ class MessagePump(metaclass=MessagePumpMeta): self, f"method=<{cls.__name__}.{handler_name}>", ) + dispatched = True await invoke(method, message) + if not dispatched: + log.event.verbose(message, ">>>", self, "method=None") # Bubble messages up the DOM (if enabled on the message) if message.bubble and self._parent and not message._stop_propagation: diff --git a/src/textual/messages.py b/src/textual/messages.py index 2b1ff4792..f23a1a8a8 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -13,6 +13,16 @@ if TYPE_CHECKING: from .widget import Widget +@rich.repr.auto +class CloseMessages(Message, verbose=True): + """Requests message pump to close.""" + + +@rich.repr.auto +class ExitApp(Message, verbose=True): + """Exit the app.""" + + @rich.repr.auto class Update(Message, verbose=True): def __init__(self, sender: MessagePump, widget: Widget): diff --git a/src/textual/widget.py b/src/textual/widget.py index 363c1f518..b28a8bd33 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2049,3 +2049,6 @@ class Widget(DOMNode): self.scroll_page_up() return True return False + + # def _on_un_mount(self) -> None: + # self.log.debug("UNMOUNTED", self) diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index a7c574ecc..8819a6839 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -18,8 +18,6 @@ from textual.css.styles import Styles, RenderStyles from textual.dom import DOMNode from textual.widget import Widget -from tests.utilities.test_app import AppTest - def test_styles_reset(): styles = Styles() @@ -206,88 +204,3 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in with pytest.raises(StyleValueError): widget.styles.width = size_dimension_input - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "overflow_y,scrollbar_gutter,scrollbar_size,text_length,expected_text_widget_width,expects_vertical_scrollbar", - ( - # ------------------------------------------------ - # ----- Let's start with `overflow-y: auto`: - # short text: full width, no scrollbar - ["auto", "auto", 1, "short_text", 80, False], - # long text: reduced width, scrollbar - ["auto", "auto", 1, "long_text", 78, True], - # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["auto", "stable", 1, "short_text", 78, False], - # long text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["auto", "stable", 1, "long_text", 78, True], - # ------------------------------------------------ - # ----- And now let's see the behaviour with `overflow-y: scroll`: - # short text: reduced width, scrollbar - ["scroll", "auto", 1, "short_text", 78, True], - # long text: reduced width, scrollbar - ["scroll", "auto", 1, "long_text", 78, True], - # short text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["scroll", "stable", 1, "short_text", 78, True], - # long text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["scroll", "stable", 1, "long_text", 78, True], - # ------------------------------------------------ - # ----- Finally, let's check the behaviour with `overflow-y: hidden`: - # short text: full width, no scrollbar - ["hidden", "auto", 1, "short_text", 80, False], - # long text: full width, no scrollbar - ["hidden", "auto", 1, "long_text", 80, False], - # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["hidden", "stable", 1, "short_text", 78, False], - # long text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["hidden", "stable", 1, "long_text", 78, False], - # ------------------------------------------------ - # ----- Bonus round with a custom scrollbar size, now that we can set this: - ["auto", "auto", 3, "short_text", 80, False], - ["auto", "auto", 3, "long_text", 77, True], - ["scroll", "auto", 3, "short_text", 77, True], - ["scroll", "stable", 3, "short_text", 77, True], - ["hidden", "auto", 3, "long_text", 80, False], - ["hidden", "stable", 3, "short_text", 77, False], - ), -) -async def test_scrollbar_gutter( - overflow_y: str, - scrollbar_gutter: str, - scrollbar_size: int, - text_length: Literal["short_text", "long_text"], - expected_text_widget_width: int, - expects_vertical_scrollbar: bool, -): - from rich.text import Text - from textual.geometry import Size - - class TextWidget(Widget): - def render(self) -> Text: - text_multiplier = 10 if text_length == "long_text" else 2 - return Text( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a." - * text_multiplier - ) - - container = Widget() - container.styles.height = 3 - container.styles.overflow_y = overflow_y - container.styles.scrollbar_gutter = scrollbar_gutter - if scrollbar_size > 1: - container.styles.scrollbar_size_vertical = scrollbar_size - - text_widget = TextWidget() - text_widget.styles.height = "auto" - container._add_child(text_widget) - - class MyTestApp(AppTest): - def compose(self) -> ComposeResult: - yield container - - app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10)) - await app.boot_and_shutdown() - - assert text_widget.outer_size.width == expected_text_widget_width - assert container.scrollbars_enabled[0] is expects_vertical_scrollbar diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2f04c9314..0c2f6f5f5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -8,6 +8,7 @@ from textual.widgets import Input, Button # --- Layout related stuff --- + def test_grid_layout_basic(snap_compare): assert snap_compare("docs/examples/guide/layout/grid_layout1.py") @@ -41,6 +42,7 @@ def test_dock_layout_sidebar(snap_compare): # When adding a new widget, ideally we should also create a snapshot test # from these examples which test rendering and simple interactions with it. + def test_checkboxes(snap_compare): """Tests checkboxes but also acts a regression test for using width: auto in a Horizontal layout context.""" @@ -65,20 +67,20 @@ def test_input_and_focus(snap_compare): assert snap_compare("docs/examples/widgets/input.py", press=press) # Assert that the state of the Input is what we'd expect - app: App = snap_compare.app - input: Input = app.query_one(Input) - assert input.value == "Darren" - assert input.cursor_position == 6 - assert input.view_position == 0 + # app: App = snap_compare.app + # input: Input = app.query_one(Input) + # assert input.value == "Darren" + # assert input.cursor_position == 6 + # assert input.view_position == 0 def test_buttons_render(snap_compare): # Testing button rendering. We press tab to focus the first button too. assert snap_compare("docs/examples/widgets/button.py", press=["tab"]) - app = snap_compare.app - button: Button = app.query_one(Button) - assert app.focused is button + # app = snap_compare.app + # button: Button = app.query_one(Button) + # assert app.focused is button def test_datatable_render(snap_compare): @@ -99,7 +101,9 @@ def test_header_render(snap_compare): # If any of these change, something has likely broken, so snapshot each of them. PATHS = [ - str(PurePosixPath(path)) for path in Path("docs/examples/styles").iterdir() if path.suffix == ".py" + str(PurePosixPath(path)) + for path in Path("docs/examples/styles").iterdir() + if path.suffix == ".py" ] diff --git a/tests/test_integration_scrolling.py b/tests/test_integration_scrolling.py deleted file mode 100644 index 0cd862977..000000000 --- a/tests/test_integration_scrolling.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from typing import Sequence, cast - -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 - -pytestmark = pytest.mark.integration_test - -SCREEN_SIZE = Size(100, 30) - - -@pytest.mark.skip("Needs a rethink") -@pytest.mark.asyncio -@pytest.mark.parametrize( - ( - "screen_size", - "placeholders_count", - "scroll_to_placeholder_id", - "scroll_to_animate", - "waiting_duration", - "last_screen_expected_placeholder_ids", - ), - ( - [SCREEN_SIZE, 10, None, None, 0.01, (0, 1, 2, 3, 4)], - [SCREEN_SIZE, 10, "placeholder_3", False, 0.01, (0, 1, 2, 3, 4)], - [SCREEN_SIZE, 10, "placeholder_5", False, 0.01, (1, 2, 3, 4, 5)], - [SCREEN_SIZE, 10, "placeholder_7", False, 0.01, (3, 4, 5, 6, 7)], - [SCREEN_SIZE, 10, "placeholder_9", False, 0.01, (5, 6, 7, 8, 9)], - # N.B. Scroll duration is hard-coded to 0.2 in the `scroll_to_widget` method atm - # Waiting for this duration should allow us to see the scroll finished: - [SCREEN_SIZE, 10, "placeholder_9", True, 0.21, (5, 6, 7, 8, 9)], - # After having waited for approximately half of the scrolling duration, we should - # see the middle Placeholders as we're scrolling towards the last of them. - [SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (4, 5, 6, 7, 8)], - ), -) -async def test_scroll_to_widget( - screen_size: Size, - placeholders_count: int, - scroll_to_animate: bool | None, - scroll_to_placeholder_id: str | None, - waiting_duration: float | None, - last_screen_expected_placeholder_ids: Sequence[int], -): - class VerticalContainer(Widget): - DEFAULT_CSS = """ - VerticalContainer { - layout: vertical; - overflow: hidden auto; - } - VerticalContainer Placeholder { - margin: 1 0; - height: 5; - } - """ - - class MyTestApp(AppTest): - DEFAULT_CSS = """ - Placeholder { - height: 5; /* minimal height to see the name of a Placeholder */ - } - """ - - 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="scroll_to_widget") - - async with app.in_running_state(waiting_duration_after_yield=waiting_duration or 0): - if scroll_to_placeholder_id: - target_widget_container = cast(Widget, app.query("#root").first()) - target_widget = cast( - Widget, app.query(f"#{scroll_to_placeholder_id}").first() - ) - target_widget_container.scroll_to_widget( - target_widget, animate=scroll_to_animate - ) - - last_display_capture = app.last_display_capture - - placeholders_visibility_by_id = { - id_: f"placeholder_{id_}" in last_display_capture - for id_ in range(placeholders_count) - } - print(placeholders_visibility_by_id) - # Let's start by checking placeholders that should be visible: - for placeholder_id in last_screen_expected_placeholder_ids: - assert placeholders_visibility_by_id[placeholder_id] is True, ( - f"Placeholder '{placeholder_id}' should be visible but isn't" - f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" - ) - - # Ok, now for placeholders that should *not* be visible: - # We're simply going to check that all the placeholders that are not in - # `last_screen_expected_placeholder_ids` are not on the screen: - last_screen_expected_out_of_viewport_placeholder_ids = sorted( - tuple( - set(range(placeholders_count)) - set(last_screen_expected_placeholder_ids) - ) - ) - for placeholder_id in last_screen_expected_out_of_viewport_placeholder_ids: - assert placeholders_visibility_by_id[placeholder_id] is False, ( - f"Placeholder '{placeholder_id}' should not be visible but is" - f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" - ) diff --git a/tests/test_screens.py b/tests/test_screens.py index cea3179e3..0841faf51 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -89,4 +89,4 @@ async def test_screens(): screen1.remove() screen2.remove() screen3.remove() - await app.shutdown() + await app._shutdown() diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py deleted file mode 100644 index 25678f28d..000000000 --- a/tests/utilities/test_app.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import io -from math import ceil -from pathlib import Path -from time import monotonic -from typing import AsyncContextManager, cast, ContextManager -from unittest import mock - -from rich.console import Console - -from textual import events, errors -from textual._ansi_sequences import SYNC_START -from textual._clock import _Clock -from textual._context import active_app -from textual.app import App, ComposeResult -from textual.app import WINDOWS -from textual.driver import Driver -from textual.geometry import Size, Region - - -# 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): - # Tests will log in "/tests/test.[test name].log": - log_path = Path(__file__).parent.parent / f"test.{test_name}.log" - super().__init__( - driver_class=DriverTest, - ) - - # Let's disable all features by default - self.features = frozenset() - - # We need this so the "start buffeting"` is always sent for a screen refresh, - # whatever the environment: - # (we use it to slice the output into distinct full screens displays) - self._sync_available = True - - 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, - *, - time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks - waiting_duration_after_initialisation: float = 1, - waiting_duration_after_yield: float = 0, - ) -> AsyncContextManager[ClockMock]: - async def run_app() -> None: - await self._process_messages() - - @contextlib.asynccontextmanager - async def get_running_state_context_manager(): - with mock_textual_timers( - ticks_granularity_fps=time_mocking_ticks_granularity_fps - ) as clock_mock: - run_task = asyncio.create_task(run_app()) - - # We have to do this because `run_app()` is running in its own async task, and our test is going to - # run in this one - so the app must also be the active App in our current context: - self._set_active() - - await clock_mock.advance_clock(waiting_duration_after_initialisation) - # make sure the App has entered its main loop at this stage: - assert self._driver is not None - - await self.force_full_screen_update() - - # And now it's time to pass the torch on to the test function! - # We provide the `move_clock_forward` function to it, - # so it can also do some time-based Textual stuff if it needs to: - yield clock_mock - - await clock_mock.advance_clock(waiting_duration_after_yield) - - # Make sure our screen is up-to-date before exiting the context manager, - # so tests using our `last_display_capture` for example can assert things on a fully refreshed screen: - await self.force_full_screen_update() - - # End of simulated time: we just shut down ourselves: - assert not run_task.done() - await self.shutdown() - await run_task - - return get_running_state_context_manager() - - async def boot_and_shutdown( - self, - *, - waiting_duration_after_initialisation: float = 0.001, - waiting_duration_before_shutdown: float = 0, - ): - """Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases""" - async with self.in_running_state( - waiting_duration_after_initialisation=waiting_duration_after_initialisation, - waiting_duration_after_yield=waiting_duration_before_shutdown, - ): - pass - - 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.visible_widgets: - return "" - - x -= region.x - y -= region.y - lines = widget.render_lines(Region(0, y, region.width, 1)) - if not lines: - return "" - end = 0 - for segment in lines[0]: - end += segment.cell_length - if x < end: - return segment.text[0] - return "" - - async def force_full_screen_update( - self, *, repaint: bool = True, layout: bool = True - ) -> None: - try: - screen = self.screen - except IndexError: - return # the app may not have a screen yet - - # We artificially tell the Compositor that the whole area should be refreshed - screen._compositor._dirty_regions = { - 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: - screen._dirty_widgets.add(screen) - screen._on_timer_update() - - await let_asyncio_process_some_events() - - def _handle_exception(self, error: Exception) -> None: - # In tests we want the errors to be raised, rather than printed to a Console - raise error - - def run(self): - raise NotImplementedError( - "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`" - ) - - @property - def active_app(self) -> App | None: - return active_app.get() - - @property - def total_capture(self) -> str | None: - return self.console.file.getvalue() - - @property - def last_display_capture(self) -> str | None: - total_capture = self.total_capture - if not total_capture: - return None - screen_captures = total_capture.split(SYNC_START) - for single_screen_capture in reversed(screen_captures): - if len(single_screen_capture) > 30: - # let's return the last occurrence of a screen that seem to be properly "fully-paint" - return single_screen_capture - return None - - @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=False, - legacy_windows=False, - ) - - @property - def file(self) -> io.StringIO: - return cast(io.StringIO, self._file) - - @property - def is_dumb_terminal(self) -> bool: - return False - - -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 - - -# It seems that we have to give _way more_ time to `asyncio` on Windows in order to see our different awaiters -# properly triggered when we pause our own "move clock forward" loop. -# It could be caused by the fact that the time resolution for `asyncio` on this platform seems rather low: -# > The resolution of the monotonic clock on Windows is usually around 15.6 msec. -# > The best resolution is 0.5 msec. -# @link https://docs.python.org/3/library/asyncio-platforms.html: -ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.005 - - -async def let_asyncio_process_some_events() -> None: - await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD) - - -class ClockMock(_Clock): - # To avoid issues with floats we will store the current time as an integer internally. - # Tenths of microseconds should be a good enough granularity: - TIME_RESOLUTION = 10_000_000 - - def __init__( - self, - *, - ticks_granularity_fps: int = 60, - ): - self._ticks_granularity_fps = ticks_granularity_fps - self._single_tick_duration = int(self.TIME_RESOLUTION / ticks_granularity_fps) - self._start_time: int = -1 - self._current_time: int = -1 - # For each call to our `sleep` method we will store an asyncio.Event - # and the time at which we should trigger it: - self._pending_sleep_events: dict[int, list[asyncio.Event]] = {} - - def get_time_no_wait(self) -> float: - if self._current_time == -1: - self._start_clock() - - return self._current_time / self.TIME_RESOLUTION - - async def sleep(self, seconds: float) -> None: - event = asyncio.Event() - internal_waiting_duration = int(seconds * self.TIME_RESOLUTION) - target_event_monotonic_time = self._current_time + internal_waiting_duration - self._pending_sleep_events.setdefault(target_event_monotonic_time, []).append( - event - ) - # Ok, let's wait for this Event - # (which can only be "unlocked" by calls to `advance_clock()`) - await event.wait() - - async def advance_clock(self, seconds: float) -> None: - """ - Artificially advance the Textual clock forward. - - Args: - seconds: for each second we will artificially tick `ticks_granularity_fps` times - """ - if self._current_time == -1: - self._start_clock() - - ticks_count = ceil(seconds * self._ticks_granularity_fps) - activated_timers_count_total = 0 # useful when debugging this code :-) - for tick_counter in range(ticks_count): - self._current_time += self._single_tick_duration - activated_timers_count = self._check_sleep_timers_to_activate() - activated_timers_count_total += activated_timers_count - # Now that we likely unlocked some occurrences of `await sleep(duration)`, - # let's give an opportunity to asyncio-related stuff to happen: - if activated_timers_count: - await let_asyncio_process_some_events() - - await let_asyncio_process_some_events() - - def _start_clock(self) -> None: - # N.B. `start_time` is not actually used, but it is useful to have when we set breakpoints there :-) - self._start_time = self._current_time = int(monotonic() * self.TIME_RESOLUTION) - - def _check_sleep_timers_to_activate(self) -> int: - activated_timers_count = 0 - activated_events_times_to_clear: list[int] = [] - for (monotonic_time, target_events) in self._pending_sleep_events.items(): - if self._current_time < monotonic_time: - continue # not time for you yet, dear awaiter... - # Right, let's release these waiting events! - for event in target_events: - event.set() - activated_timers_count += len(target_events) - # ...and let's mark it for removal: - activated_events_times_to_clear.append(monotonic_time) - - for event_time_to_clear in activated_events_times_to_clear: - del self._pending_sleep_events[event_time_to_clear] - - return activated_timers_count - - -def mock_textual_timers( - *, - ticks_granularity_fps: int = 60, -) -> ContextManager[ClockMock]: - @contextlib.contextmanager - def mock_textual_timers_context_manager(): - clock_mock = ClockMock(ticks_granularity_fps=ticks_granularity_fps) - with mock.patch("textual._clock._clock", new=clock_mock): - yield clock_mock - - return mock_textual_timers_context_manager() From ce26c0eba890ad04ebdd53c50ce40014d1e0fc0f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 27 Oct 2022 17:57:31 +0100 Subject: [PATCH 02/20] log unmount --- src/textual/events.py | 2 +- src/textual/message_pump.py | 6 ++---- src/textual/widget.py | 3 --- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index f5742ac03..7a88af264 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -119,7 +119,7 @@ class Compose(Event, bubble=False, verbose=True): """Sent to a widget to request it to compose and mount children.""" -class Mount(Event, bubble=False, verbose=True): +class Mount(Event, bubble=False, verbose=False): """Sent when a widget is *mounted* and may receive messages.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 2102602e1..e1a67df4a 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -266,7 +266,7 @@ class MessagePump(metaclass=MessagePumpMeta): self.app.screen._invoke_later(message.callback) def _close_messages_no_wait(self) -> None: - """Request the message queue to exit.""" + """Request the message queue to immediately exit.""" self._message_queue.put_nowait(messages.CloseMessages(sender=self)) async def _on_close_messages(self, message: messages.CloseMessages) -> None: @@ -374,8 +374,6 @@ class MessagePump(metaclass=MessagePumpMeta): self.app._handle_exception(error) break - # log("CLOSED", self) - async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. @@ -439,7 +437,7 @@ class MessagePump(metaclass=MessagePumpMeta): dispatched = True await invoke(method, message) if not dispatched: - log.event.verbose(message, ">>>", self, "method=None") + log.event.verbosity(message.verbose)(message, ">>>", self, "method=None") # Bubble messages up the DOM (if enabled on the message) if message.bubble and self._parent and not message._stop_propagation: diff --git a/src/textual/widget.py b/src/textual/widget.py index b28a8bd33..363c1f518 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2049,6 +2049,3 @@ class Widget(DOMNode): self.scroll_page_up() return True return False - - # def _on_un_mount(self) -> None: - # self.log.debug("UNMOUNTED", self) From bb80aeb7f92dc0dd68ceac186ea38bf9040f956d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 28 Oct 2022 21:02:23 +0100 Subject: [PATCH 03/20] added pilot --- sandbox/will/dictionary.css | 26 ++++ sandbox/will/dictionary.py | 82 ++++++++++ sandbox/will/pride.py | 5 +- src/textual/__init__.py | 4 +- src/textual/_doc.py | 30 +++- src/textual/_pilot.py | 29 ++++ src/textual/app.py | 202 +++++++++---------------- src/textual/driver.py | 5 + src/textual/drivers/headless_driver.py | 4 + 9 files changed, 244 insertions(+), 143 deletions(-) create mode 100644 sandbox/will/dictionary.css create mode 100644 sandbox/will/dictionary.py create mode 100644 src/textual/_pilot.py diff --git a/sandbox/will/dictionary.css b/sandbox/will/dictionary.css new file mode 100644 index 000000000..6bca8b9f5 --- /dev/null +++ b/sandbox/will/dictionary.css @@ -0,0 +1,26 @@ +Screen { + background: $panel; +} + +Input { + dock: top; + margin: 1 0; +} + +#results { + width: auto; + min-height: 100%; + padding: 0 1; +} + +#results-container { + background: $background 50%; + margin: 0 0 1 0; + height: 100%; + overflow: hidden auto; + border: tall $background; +} + +#results-container:focus { + border: tall $accent; +} diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py new file mode 100644 index 000000000..4a427c394 --- /dev/null +++ b/sandbox/will/dictionary.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import asyncio + +try: + import httpx +except ImportError: + raise ImportError("Please install httpx with 'pip install httpx' ") + +from rich.markdown import Markdown + +from textual.app import App, ComposeResult +from textual.containers import Content +from textual.widgets import Static, Input + + +class DictionaryApp(App): + """Searches ab dictionary API as-you-type.""" + + CSS_PATH = "dictionary.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Search for a word") + yield Content(Static(id="results"), id="results-container") + + def on_mount(self) -> None: + """Called when app starts.""" + # Give the input focus, so we can start typing straight away + self.query_one(Input).focus() + + async def on_input_changed(self, message: Input.Changed) -> None: + """A coroutine to handle a text changed message.""" + if message.value: + # Look up the word in the background + asyncio.create_task(self.lookup_word(message.value)) + else: + # Clear the results + self.query_one("#results", Static).update() + + async def lookup_word(self, word: str) -> None: + """Looks up a word.""" + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + async with httpx.AsyncClient() as client: + results = (await client.get(url)).json() + + if word == self.query_one(Input).value: + markdown = self.make_word_markdown(results) + self.query_one("#results", Static).update(Markdown(markdown)) + + def make_word_markdown(self, results: object) -> str: + """Convert the results in to markdown.""" + lines = [] + if isinstance(results, dict): + lines.append(f"# {results['title']}") + lines.append(results["message"]) + elif isinstance(results, list): + for result in results: + lines.append(f"# {result['word']}") + lines.append("") + for meaning in result.get("meanings", []): + lines.append(f"_{meaning['partOfSpeech']}_") + lines.append("") + for definition in meaning.get("definitions", []): + lines.append(f" - {definition['definition']}") + lines.append("---") + + return "\n".join(lines) + + +if __name__ == "__main__": + app = DictionaryApp() + + async def run(): + async with app.run_managed() as pilot: + await pilot.press(*"Hello") + await pilot.pause(2) + await pilot.press(*" World!") + await pilot.pause(3) + + import asyncio + + asyncio.run(run()) diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py index a51f474bb..788f6be03 100644 --- a/sandbox/will/pride.py +++ b/sandbox/will/pride.py @@ -21,9 +21,8 @@ if __name__ == "__main__": from rich import print async def run_app(): - async with app.run_async(quit_after=5) as result: - print(result) - print(app.tree) + async with app.run_managed() as pilot: + await pilot.pause(5) import asyncio diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 3935489be..1a712e0c8 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -51,7 +51,9 @@ class Logger: try: app = active_app.get() except LookupError: - raise LoggerError("Unable to log without an active app.") from None + print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()]) + print(*print_args) + return if app.devtools is None or not app.devtools.is_connected: return diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 17918e351..4b536b9d8 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import os import shlex from typing import Iterable @@ -69,19 +70,32 @@ def take_svg_screenshot( os.environ["LINES"] = str(rows) if app is None: + assert app_path is not None app = import_app(app_path) + assert app is not None + if title is None: title = app.title - app.run( - quit_after=5, - press=press or ["ctrl+c"], - headless=True, - screenshot=True, - screenshot_title=title, - ) - svg = app._screenshot + svg: str = "" + + async def run_app(app: App) -> None: + nonlocal svg + async with app.run_managed(headless=True) as pilot: + await pilot.press(*press) + svg = app.export_screenshot(title=title) + + asyncio.run(run_app(app)) + + # app.run( + # quit_after=5, + # press=press or ["ctrl+c"], + # headless=True, + # screenshot=True, + # screenshot_title=title, + # ) + return svg diff --git a/src/textual/_pilot.py b/src/textual/_pilot.py new file mode 100644 index 000000000..bc720c2ea --- /dev/null +++ b/src/textual/_pilot.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio +from typing import Iterable, TYPE_CHECKING + +if TYPE_CHECKING: + from .app import App + + +class Pilot: + def __init__(self, app: App) -> None: + self._app = app + + async def press(self, *keys: str) -> None: + """Simulate key-presses. + + Args: + *key: Keys to press. + + """ + await self._app._press_keys(keys) + + async def pause(self, delay: float = 50 / 1000) -> None: + """Insert a pause. + + Args: + delay (float, optional): Seconds to pause. Defaults to 50ms. + """ + await asyncio.sleep(delay) diff --git a/src/textual/app.py b/src/textual/app.py index 0a82bf700..68753fd20 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,7 +13,17 @@ from contextlib import redirect_stderr, redirect_stdout from datetime import datetime from pathlib import Path, PurePath from time import perf_counter -from typing import Any, Generic, Iterable, Type, TYPE_CHECKING, TypeVar, cast, Union +from typing import ( + Any, + Callable, + Generic, + Iterable, + Type, + TYPE_CHECKING, + TypeVar, + cast, + Union, +) from weakref import WeakSet, WeakValueDictionary from ._ansi_sequences import SYNC_END, SYNC_START @@ -171,7 +181,7 @@ class App(Generic[ReturnType], DOMNode): if no_color is not None: self._filter = Monochrome() self.console = Console( - file=(_NullFile() if self.is_headless else sys.__stdout__), + file=sys.__stdout__ if sys.__stdout__ is not None else _NullFile(), markup=False, highlight=False, emoji=False, @@ -301,7 +311,7 @@ class App(Generic[ReturnType], DOMNode): bool: True if the app is in headless mode. """ - return "headless" in self.features + return False if self._driver is None else self._driver.is_headless @property def screen_stack(self) -> list[Screen]: @@ -572,10 +582,9 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - @classmethod - async def _press_keys(cls, app: App, press: Iterable[str]) -> None: + async def _press_keys(self, press: Iterable[str]) -> None: """A task to send key events.""" - assert press + app = self driver = app._driver assert driver is not None await asyncio.sleep(0.02) @@ -612,16 +621,44 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.05) await asyncio.sleep(0.02) await app._animator.wait_for_idle() - print("EXITING") - app.exit() @asynccontextmanager + async def run_managed(self, headless: bool = False): + """Context manager to run the app. + + Args: + headless (bool, optional): Enable headless mode. Defaults to False. + + """ + + from ._pilot import Pilot + + ready_event = asyncio.Event() + + async def on_ready(): + ready_event.set() + + async def run_app(app: App) -> None: + await app._process_messages(ready_callback=on_ready, headless=headless) + + self._set_active() + asyncio.create_task(run_app(self)) + + # Wait for the app to be ready + await ready_event.wait() + + # Yield a pilot object + yield Pilot(self) + + await self._shutdown() + async def run_async( self, *, headless: bool = False, quit_after: float | None = None, press: Iterable[str] | None = None, + ready_callback: Callable | None = None, ): """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. @@ -639,33 +676,26 @@ class App(Generic[ReturnType], DOMNode): """ app = self - if headless: - app.features = cast( - "frozenset[FeatureFlag]", app.features.union({"headless"}) - ) if quit_after is not None: app.set_timer(quit_after, app.exit) - if press is not None: - - async def press_keys_task(): + async def app_ready() -> None: + """Called by the message loop when the app is ready.""" + if press: asyncio.create_task(self._press_keys(app, press)) + if ready_callback is not None: + await invoke(ready_callback) - await app._process_messages(ready_callback=press_keys_task) - - else: - await app._process_messages() - - yield app.return_value - + await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() + return app.return_value def run( self, *, - quit_after: float | None = None, headless: bool = False, + quit_after: float | None = None, press: Iterable[str] | None = None, screenshot: bool = False, screenshot_title: str | None = None, @@ -673,9 +703,9 @@ class App(Generic[ReturnType], DOMNode): """The main entry point for apps. Args: + headless (bool, optional): Run in "headless" mode (don't write to stdout). quit_after (float | None, optional): Quit after a given number of seconds, or None to run forever. Defaults to None. - headless (bool, optional): Run in "headless" mode (don't write to stdout). press (str, optional): An iterable of keys to simulate being pressed. screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. @@ -685,12 +715,19 @@ class App(Generic[ReturnType], DOMNode): """ async def run_app() -> None: - async with self.run_async( - quit_after=quit_after, headless=headless, press=press - ): + """Run the app.""" + + def take_screenshot() -> None: if screenshot: self._screenshot = self.export_screenshot(title=screenshot_title) + await self.run_async( + quit_after=quit_after, + headless=headless, + press=press, + ready_callback=take_screenshot, + ) + if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: asyncio.run(run_app()) @@ -700,106 +737,6 @@ class App(Generic[ReturnType], DOMNode): event_loop.run_until_complete(run_app()) return self.return_value - # def run( - # self, - # *, - # quit_after: float | None = None, - # headless: bool = False, - # press: Iterable[str] | None = None, - # screenshot: bool = False, - # screenshot_title: str | None = None, - # ) -> ReturnType | None: - # """The main entry point for apps. - - # Args: - # quit_after (float | None, optional): Quit after a given number of seconds, or None - # to run forever. Defaults to None. - # headless (bool, optional): Run in "headless" mode (don't write to stdout). - # press (str, optional): An iterable of keys to simulate being pressed. - # screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. - # screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. - - # Returns: - # ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. - # """ - - # if headless: - # self.features = cast( - # "frozenset[FeatureFlag]", self.features.union({"headless"}) - # ) - - # async def run_app() -> None: - # if quit_after is not None: - # self.set_timer(quit_after, self.shutdown) - # if press is not None: - # app = self - - # async def press_keys() -> None: - # """A task to send key events.""" - # assert press - # driver = app._driver - # assert driver is not None - # await asyncio.sleep(0.02) - # for key in press: - # if key == "_": - # print("(pause 50ms)") - # await asyncio.sleep(0.05) - # elif key.startswith("wait:"): - # _, wait_ms = key.split(":") - # print(f"(pause {wait_ms}ms)") - # await asyncio.sleep(float(wait_ms) / 1000) - # else: - # if len(key) == 1 and not key.isalnum(): - # key = ( - # unicodedata.name(key) - # .lower() - # .replace("-", "_") - # .replace(" ", "_") - # ) - # original_key = REPLACED_KEYS.get(key, key) - # try: - # char = unicodedata.lookup( - # original_key.upper().replace("_", " ") - # ) - # except KeyError: - # char = key if len(key) == 1 else None - # print(f"press {key!r} (char={char!r})") - # key_event = events.Key(self, key, char) - # driver.send_event(key_event) - # # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race - # # condition between widget-level key handling and app/screen level handling. - # # More information here: https://github.com/Textualize/textual/issues/1009 - # # This conditional sleep can be removed after that issue is closed. - # if key == "tab": - # await asyncio.sleep(0.05) - # await asyncio.sleep(0.02) - - # await app._animator.wait_for_idle() - - # if screenshot: - # self._screenshot = self.export_screenshot( - # title=screenshot_title - # ) - # await self.shutdown() - - # async def press_keys_task(): - # """Press some keys in the background.""" - # asyncio.create_task(press_keys()) - - # await self._process_messages(ready_callback=press_keys_task) - # else: - # await self._process_messages() - - # if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: - # # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: - # asyncio.run(run_app()) - # else: - # # However, this works with Python<3.10: - # event_loop = asyncio.get_event_loop() - # event_loop.run_until_complete(run_app()) - - # return self._return_value - async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_path is not None: @@ -1125,7 +1062,7 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.clear() async def _process_messages( - self, ready_callback: CallbackType | None = None + self, ready_callback: CallbackType | None = None, headless: bool = False ) -> None: self._set_active() @@ -1185,8 +1122,11 @@ class App(Generic[ReturnType], DOMNode): await self.animator.start() await self._ready() + if ready_callback is not None: - await ready_callback() + ready_result = ready_callback() + if inspect.isawaitable(ready_result): + await ready_result self._running = True @@ -1209,13 +1149,13 @@ class App(Generic[ReturnType], DOMNode): driver: Driver driver_class = cast( "type[Driver]", - HeadlessDriver if self.is_headless else self.driver_class, + HeadlessDriver if headless else self.driver_class, ) driver = self._driver = driver_class(self.console, self) driver.start_application_mode() try: - if self.is_headless: + if headless: await run_process_messages() else: if self.devtools is not None: diff --git a/src/textual/driver.py b/src/textual/driver.py index 559c5e014..0eb3767c3 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -21,6 +21,11 @@ class Driver(ABC): self._loop = asyncio.get_running_loop() self._mouse_down_time = _clock.get_time_no_wait() + @property + def is_headless(self) -> bool: + """Check if the driver is 'headless'""" + return False + def send_event(self, event: events.Event) -> None: asyncio.run_coroutine_threadsafe( self._target.post_message(event), loop=self._loop diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py index cdde957b9..15c31a9ae 100644 --- a/src/textual/drivers/headless_driver.py +++ b/src/textual/drivers/headless_driver.py @@ -9,6 +9,10 @@ from .. import events class HeadlessDriver(Driver): """A do-nothing driver for testing.""" + @property + def is_headless(self) -> bool: + return True + def _get_terminal_size(self) -> tuple[int, int]: width: int | None = 80 height: int | None = 25 From 4370198bf23b3b4cc0e8b25c598799ce5aa8e442 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 28 Oct 2022 21:43:23 +0100 Subject: [PATCH 04/20] added auto pilot --- sandbox/will/dictionary.py | 16 ++--- src/textual/_doc.py | 24 +++---- src/textual/app.py | 105 ++++++++-------------------- src/textual/{_pilot.py => pilot.py} | 14 +++- 4 files changed, 61 insertions(+), 98 deletions(-) rename src/textual/{_pilot.py => pilot.py} (68%) diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py index 4a427c394..5bb6d8e69 100644 --- a/sandbox/will/dictionary.py +++ b/sandbox/will/dictionary.py @@ -70,13 +70,13 @@ class DictionaryApp(App): if __name__ == "__main__": app = DictionaryApp() - async def run(): - async with app.run_managed() as pilot: - await pilot.press(*"Hello") - await pilot.pause(2) - await pilot.press(*" World!") - await pilot.pause(3) + from textual.pilot import Pilot - import asyncio + async def auto_pilot(pilot: Pilot) -> None: + await pilot.press(*"Hello") + await pilot.pause(2) + await pilot.press(*" World!") + await pilot.pause(3) + pilot.app.exit() - asyncio.run(run()) + app.run(auto_pilot=auto_pilot) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 4b536b9d8..c57b368b4 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -6,6 +6,7 @@ import shlex from typing import Iterable from textual.app import App +from textual.pilot import Pilot from textual._import_app import import_app @@ -78,23 +79,16 @@ def take_svg_screenshot( if title is None: title = app.title - svg: str = "" + svg: str | None = "" - async def run_app(app: App) -> None: - nonlocal svg - async with app.run_managed(headless=True) as pilot: - await pilot.press(*press) - svg = app.export_screenshot(title=title) + async def auto_pilot(pilot: Pilot) -> None: + app = pilot.app + await pilot.press(*press) + svg = app.export_screenshot(title=title) + app.exit(svg) - asyncio.run(run_app(app)) - - # app.run( - # quit_after=5, - # press=press or ["ctrl+c"], - # headless=True, - # screenshot=True, - # screenshot_title=title, - # ) + svg = app.run(headless=True, auto_pilot=auto_pilot) + assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index 68753fd20..a6cd98fab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -15,7 +15,9 @@ from pathlib import Path, PurePath from time import perf_counter from typing import ( Any, + Awaitable, Callable, + Coroutine, Generic, Iterable, Type, @@ -62,7 +64,12 @@ from .widget import AwaitMount, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient + from .pilot import Pilot +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias PLATFORM = platform.system() WINDOWS = PLATFORM == "Windows" @@ -100,6 +107,10 @@ ComposeResult = Iterable[Widget] RenderResult = RenderableType +# AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Awaitable[None]]" +AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" + + class AppError(Exception): pass @@ -582,13 +593,13 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - async def _press_keys(self, press: Iterable[str]) -> None: + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" app = self driver = app._driver assert driver is not None await asyncio.sleep(0.02) - for key in press: + for key in keys: if key == "_": print("(pause 50ms)") await asyncio.sleep(0.05) @@ -622,70 +633,30 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.02) await app._animator.wait_for_idle() - @asynccontextmanager - async def run_managed(self, headless: bool = False): - """Context manager to run the app. - - Args: - headless (bool, optional): Enable headless mode. Defaults to False. - - """ - - from ._pilot import Pilot - - ready_event = asyncio.Event() - - async def on_ready(): - ready_event.set() - - async def run_app(app: App) -> None: - await app._process_messages(ready_callback=on_ready, headless=headless) - - self._set_active() - asyncio.create_task(run_app(self)) - - # Wait for the app to be ready - await ready_event.wait() - - # Yield a pilot object - yield Pilot(self) - - await self._shutdown() - async def run_async( self, *, headless: bool = False, - quit_after: float | None = None, - press: Iterable[str] | None = None, - ready_callback: Callable | None = None, - ): - """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. - - Example: - async def run_app(): - app = MyApp() - async with app.run_async() as result: - print(result) + auto_pilot: AutopilotCallbackType, + ) -> ReturnType | None: + """Run the app asynchronously. Args: - quit_after (float | None, optional): Quit after a given number of seconds, or None - to run forever. Defaults to None. - headless (bool, optional): Run in "headless" mode (don't write to stdout). - press (str, optional): An iterable of keys to simulate being pressed. + headless (bool, optional): Run in headless mode (no output). Defaults to False. + auto_pilot (AutopilotCallbackType): An auto pilot coroutine. + Returns: + ReturnType | None: App return value. """ - app = self + from .pilot import Pilot - if quit_after is not None: - app.set_timer(quit_after, app.exit) + app = self async def app_ready() -> None: """Called by the message loop when the app is ready.""" - if press: - asyncio.create_task(self._press_keys(app, press)) - if ready_callback is not None: - await invoke(ready_callback) + if auto_pilot is not None: + pilot = Pilot(app) + asyncio.create_task(auto_pilot(pilot)) await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() @@ -695,37 +666,23 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, - quit_after: float | None = None, - press: Iterable[str] | None = None, - screenshot: bool = False, - screenshot_title: str | None = None, + auto_pilot: AutopilotCallbackType, ) -> ReturnType | None: - """The main entry point for apps. + """Run the app. Args: - headless (bool, optional): Run in "headless" mode (don't write to stdout). - quit_after (float | None, optional): Quit after a given number of seconds, or None - to run forever. Defaults to None. - press (str, optional): An iterable of keys to simulate being pressed. - screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. - screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. + headless (bool, optional): Run in headless mode (no output). Defaults to False. + auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: - ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. + ReturnType | None: App return value. """ async def run_app() -> None: """Run the app.""" - - def take_screenshot() -> None: - if screenshot: - self._screenshot = self.export_screenshot(title=screenshot_title) - await self.run_async( - quit_after=quit_after, headless=headless, - press=press, - ready_callback=take_screenshot, + auto_pilot=auto_pilot, ) if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: diff --git a/src/textual/_pilot.py b/src/textual/pilot.py similarity index 68% rename from src/textual/_pilot.py rename to src/textual/pilot.py index bc720c2ea..79472facf 100644 --- a/src/textual/_pilot.py +++ b/src/textual/pilot.py @@ -1,16 +1,28 @@ from __future__ import annotations +import rich.repr + import asyncio -from typing import Iterable, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from .app import App +@rich.repr.auto(angular=True) class Pilot: + """Pilot object to drive an app.""" + def __init__(self, app: App) -> None: self._app = app + def __rich_repr__(self) -> rich.repr.Result: + yield "app", "self._app" + + @property + def app(self) -> App: + return self._app + async def press(self, *keys: str) -> None: """Simulate key-presses. From 269ff4883e1c7b0432d10b6040a31d23a89dc378 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 10:03:26 +0100 Subject: [PATCH 05/20] test fix --- src/textual/_doc.py | 2 -- tests/test_auto_refresh.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index c57b368b4..b3021c4fc 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -79,8 +79,6 @@ def take_svg_screenshot( if title is None: title = app.title - svg: str | None = "" - async def auto_pilot(pilot: Pilot) -> None: app = pilot.app await pilot.press(*press) diff --git a/tests/test_auto_refresh.py b/tests/test_auto_refresh.py index d5122a7a4..c9eacf469 100644 --- a/tests/test_auto_refresh.py +++ b/tests/test_auto_refresh.py @@ -1,6 +1,8 @@ +import asyncio from time import time from textual.app import App +from textual.pilot import Pilot class RefreshApp(App[float]): @@ -22,7 +24,10 @@ class RefreshApp(App[float]): def test_auto_refresh(): app = RefreshApp() - elapsed = app.run(quit_after=1, headless=True) + async def quit_after(pilot: Pilot) -> None: + await asyncio.sleep(1) + + elapsed = app.run(auto_pilot=quit_after, headless=True) assert elapsed is not None # CI can run slower, so we need to give this a bit of margin assert 0.2 <= elapsed < 0.8 From 264b4fe73340258b4bc0ae59aeaec9c437fa81c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 10:53:50 +0100 Subject: [PATCH 06/20] run test context manager --- CHANGELOG.md | 5 +++++ src/textual/app.py | 42 +++++++++++++++++++++++++++++++++++++++++- src/textual/pilot.py | 15 ++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d55e99b..81cbdbf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error +- Dropped quit_after, screenshot, and screenshot_title from App.run, which can all be done via auto_pilot +- Widgets are now closed in reversed DOM order ### Added +- Added Unmount event - Added App.run_async method +- Added App.run_test context manager +- Added auto_pilot to App.run and App.run_async ## [0.2.1] - 2022-10-23 diff --git a/src/textual/app.py b/src/textual/app.py index a6cd98fab..7d2f93fb3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -633,6 +633,38 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.02) await app._animator.wait_for_idle() + @asynccontextmanager + async def run_test(self, *, headless: bool = True): + """An asynchronous context manager for testing app. + + Args: + headless (bool, optional): Run in headless mode (no output or input). Defaults to True. + + """ + from .pilot import Pilot + + app = self + app_ready_event = asyncio.Event() + + def on_app_ready() -> None: + """Called when app is ready to process events.""" + app_ready_event.set() + + async def run_app(app) -> None: + await app._process_messages(ready_callback=on_app_ready, headless=headless) + + # Launch the app in the "background" + asyncio.create_task(run_app(app)) + + # Wait until the app has performed all startup routines. + await app_ready_event.wait() + + # Context manager returns pilot object to manipulate the app + yield Pilot(app) + + # Shutdown the app cleanly + await app._shutdown() + async def run_async( self, *, @@ -655,8 +687,16 @@ class App(Generic[ReturnType], DOMNode): async def app_ready() -> None: """Called by the message loop when the app is ready.""" if auto_pilot is not None: + + async def run_auto_pilot(pilot) -> None: + try: + await auto_pilot(pilot) + except Exception: + app.exit() + raise + pilot = Pilot(app) - asyncio.create_task(auto_pilot(pilot)) + asyncio.create_task(run_auto_pilot(pilot)) await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 79472facf..58b2743d1 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -17,10 +17,15 @@ class Pilot: self._app = app def __rich_repr__(self) -> rich.repr.Result: - yield "app", "self._app" + yield "app", self._app @property def app(self) -> App: + """Get a reference to the application. + + Returns: + App: The App instance. + """ return self._app async def press(self, *keys: str) -> None: @@ -39,3 +44,11 @@ class Pilot: delay (float, optional): Seconds to pause. Defaults to 50ms. """ await asyncio.sleep(delay) + + async def exit(self, result: object) -> None: + """Exit the app with the given result. + + Args: + result (object): The app result returned by `run` or `run_async`. + """ + self.app.exit(result) From 2afb00f5b357d72f84c56adcf9762dd27370a5e3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 11:44:31 +0100 Subject: [PATCH 07/20] test fix --- src/textual/app.py | 30 +++++++++++++++++++------- src/textual/message_pump.py | 5 ++++- src/textual/widget.py | 1 - tests/snapshot_tests/conftest.py | 14 +++++++----- tests/snapshot_tests/test_snapshots.py | 11 ---------- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7d2f93fb3..11f481789 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from asyncio import Task from contextlib import asynccontextmanager import inspect import io @@ -15,7 +16,6 @@ from pathlib import Path, PurePath from time import perf_counter from typing import ( Any, - Awaitable, Callable, Coroutine, Generic, @@ -654,7 +654,7 @@ class App(Generic[ReturnType], DOMNode): await app._process_messages(ready_callback=on_app_ready, headless=headless) # Launch the app in the "background" - asyncio.create_task(run_app(app)) + app_task = asyncio.create_task(run_app(app)) # Wait until the app has performed all startup routines. await app_ready_event.wait() @@ -664,12 +664,13 @@ class App(Generic[ReturnType], DOMNode): # Shutdown the app cleanly await app._shutdown() + await app_task async def run_async( self, *, headless: bool = False, - auto_pilot: AutopilotCallbackType, + auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app asynchronously. @@ -684,8 +685,11 @@ class App(Generic[ReturnType], DOMNode): app = self + auto_pilot_task: Task | None = None + async def app_ready() -> None: """Called by the message loop when the app is ready.""" + nonlocal auto_pilot_task if auto_pilot is not None: async def run_auto_pilot(pilot) -> None: @@ -696,17 +700,25 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - asyncio.create_task(run_auto_pilot(pilot)) + auto_pilot_task = asyncio.create_task(run_auto_pilot(pilot)) + + try: + await app._process_messages( + ready_callback=None if auto_pilot is None else app_ready, + headless=headless, + ) + finally: + if auto_pilot_task is not None: + await auto_pilot_task + await app._shutdown() - await app._process_messages(ready_callback=app_ready, headless=headless) - await app._shutdown() return app.return_value def run( self, *, headless: bool = False, - auto_pilot: AutopilotCallbackType, + auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app. @@ -1287,8 +1299,10 @@ class App(Generic[ReturnType], DOMNode): parent (Widget): The parent of the Widget. widget (Widget): The Widget to start. """ + widget._attach(parent) widget._start_messages() + self.app._registry.add(widget) def is_mounted(self, widget: Widget) -> bool: """Check if a widget is mounted. @@ -1321,6 +1335,7 @@ class App(Generic[ReturnType], DOMNode): async def _shutdown(self) -> None: driver = self._driver + self._running = False if driver is not None: driver.disable_input() await self._close_all() @@ -1328,7 +1343,6 @@ class App(Generic[ReturnType], DOMNode): await self._dispatch_message(events.UnMount(sender=self)) - self._running = False self._print_error_renderables() if self.devtools is not None and self.devtools.is_connected: await self._disconnect_devtools() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index e1a67df4a..c0c1e6160 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -155,7 +155,9 @@ class MessagePump(metaclass=MessagePumpMeta): return self._pending_message finally: self._pending_message = None + message = await self._message_queue.get() + if message is None: self._closed = True raise MessagePumpClosed("The message pump is now closed") @@ -289,7 +291,8 @@ class MessagePump(metaclass=MessagePumpMeta): def _start_messages(self) -> None: """Start messages task.""" - self._task = asyncio.create_task(self._process_messages()) + if self.app._running: + self._task = asyncio.create_task(self._process_messages()) async def _process_messages(self) -> None: self._running = True diff --git a/src/textual/widget.py b/src/textual/widget.py index 363c1f518..d83403482 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -595,7 +595,6 @@ class Widget(DOMNode): vertical=False, name="horizontal", thickness=self.scrollbar_size_horizontal ) self._horizontal_scrollbar.display = False - self.app._start_widget(self, scroll_bar) return scroll_bar diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index 0a0f0d55c..5a5862df7 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -59,7 +59,6 @@ def snap_compare( """ node = request.node app = import_app(app_path) - compare.app = app actual_screenshot = take_svg_screenshot( app=app, press=press, @@ -69,7 +68,9 @@ def snap_compare( if result is False: # The split and join below is a mad hack, sorry... - node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join(str(snapshot).splitlines()[1:-1]) + node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join( + str(snapshot).splitlines()[1:-1] + ) node.stash[TEXTUAL_ACTUAL_SVG_KEY] = actual_screenshot node.stash[TEXTUAL_APP_KEY] = app else: @@ -85,6 +86,7 @@ class SvgSnapshotDiff: """Model representing a diff between current screenshot of an app, and the snapshot on disk. This is ultimately intended to be used in a Jinja2 template.""" + snapshot: Optional[str] actual: Optional[str] test_name: str @@ -119,7 +121,7 @@ def pytest_sessionfinish( snapshot=str(snapshot_svg), actual=str(actual_svg), file_similarity=100 - * difflib.SequenceMatcher( + * difflib.SequenceMatcher( a=str(snapshot_svg), b=str(actual_svg) ).ratio(), test_name=name, @@ -176,7 +178,9 @@ def pytest_terminal_summary( if diffs: snapshot_report_location = config._textual_snapshot_html_report console.rule("[b red]Textual Snapshot Report", style="red") - console.print(f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" - f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n") + console.print( + f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" + f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" + ) console.print(f"[dim]{snapshot_report_location}\n") console.rule(style="red") diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 0c2f6f5f5..a4a4accaa 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -66,22 +66,11 @@ def test_input_and_focus(snap_compare): ] assert snap_compare("docs/examples/widgets/input.py", press=press) - # Assert that the state of the Input is what we'd expect - # app: App = snap_compare.app - # input: Input = app.query_one(Input) - # assert input.value == "Darren" - # assert input.cursor_position == 6 - # assert input.view_position == 0 - def test_buttons_render(snap_compare): # Testing button rendering. We press tab to focus the first button too. assert snap_compare("docs/examples/widgets/button.py", press=["tab"]) - # app = snap_compare.app - # button: Button = app.query_one(Button) - # assert app.focused is button - def test_datatable_render(snap_compare): press = ["tab", "down", "down", "right", "up", "left"] From 12c553d1953fe8cd539bab33ed8af3670f2a2311 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 13:29:32 +0100 Subject: [PATCH 08/20] shutdown scrollbas --- CHANGELOG.md | 5 +++++ sandbox/will/scroll_remove.py | 18 ++++++++++++++++++ src/textual/app.py | 5 +---- src/textual/cli/cli.py | 8 +++++++- src/textual/widget.py | 17 ++++++++++++++++- tests/test_auto_pilot.py | 23 +++++++++++++++++++++++ tests/test_test_runner.py | 21 +++++++++++++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 sandbox/will/scroll_remove.py create mode 100644 tests/test_auto_pilot.py create mode 100644 tests/test_test_runner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cbdbf06..5d605f010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.2.2] - Unreleased +### Fixed + +- Fixed issue where scrollbars weren't being unmounted + ### Changed - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error @@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added App.run_async method - Added App.run_test context manager - Added auto_pilot to App.run and App.run_async +- Added Widget._get_virtual_dom to get scrollbars ## [0.2.1] - 2022-10-23 diff --git a/sandbox/will/scroll_remove.py b/sandbox/will/scroll_remove.py new file mode 100644 index 000000000..abad13947 --- /dev/null +++ b/sandbox/will/scroll_remove.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult + +from textual.containers import Container + + +class ScrollApp(App): + + def compose(self) -> ComposeResult: + yield Container( + Container(), Container(), + id="top") + + def key_r(self) -> None: + self.query_one("#top").remove() + +if __name__ == "__main__": + app = ScrollApp() + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index 11f481789..14b46c1aa 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1605,9 +1605,6 @@ class App(Generic[ReturnType], DOMNode): await self._prune_node(widget) - # for child in remove_widgets: - # await child._close_messages() - # self._unregister(child) if parent is not None: parent.refresh(layout=True) @@ -1625,7 +1622,7 @@ class App(Generic[ReturnType], DOMNode): while stack: widget = pop() if widget.children: - yield list(widget.children) + yield [*widget.children, *widget._get_virtual_dom()] for child in widget.children: push(child) diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index 3b1df1f4b..414575d82 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import click from importlib_metadata import version +from textual.pilot import Pilot from textual._import_app import import_app, AppFail @@ -84,7 +85,12 @@ def run_app(import_name: str, dev: bool, press: str) -> None: sys.exit(1) press_keys = press.split(",") if press else None - result = app.run(press=press_keys) + + async def run_press_keys(pilot: Pilot) -> None: + if press_keys is not None: + await pilot.press(*press_keys) + + result = app.run(auto_pilot=run_press_keys) if result is not None: from rich.console import Console diff --git a/src/textual/widget.py b/src/textual/widget.py index d83403482..e43c52f17 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -359,6 +359,20 @@ class Widget(DOMNode): """Clear arrangement cache, forcing a new arrange operation.""" self._arrangement = None + def _get_virtual_dom(self) -> Iterable[Widget]: + """Get widgets not part of the DOM. + + Returns: + Iterable[Widget]: An iterable of Widgets. + + """ + if self._horizontal_scrollbar is not None: + yield self._horizontal_scrollbar + if self._vertical_scrollbar is not None: + yield self._vertical_scrollbar + if self._scrollbar_corner is not None: + yield self._scrollbar_corner + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> AwaitMount: """Mount child widgets (making this widget a container). @@ -587,6 +601,7 @@ class Widget(DOMNode): Returns: ScrollBar: ScrollBar Widget. """ + from .scrollbar import ScrollBar if self._horizontal_scrollbar is not None: @@ -600,7 +615,7 @@ class Widget(DOMNode): def _refresh_scrollbars(self) -> None: """Refresh scrollbar visibility.""" - if not self.is_scrollable: + if not self.is_scrollable or not self.container_size: return styles = self.styles diff --git a/tests/test_auto_pilot.py b/tests/test_auto_pilot.py new file mode 100644 index 000000000..dde2ad18c --- /dev/null +++ b/tests/test_auto_pilot.py @@ -0,0 +1,23 @@ +from textual.app import App +from textual.pilot import Pilot +from textual import events + + +def test_auto_pilot() -> None: + + keys_pressed: list[str] = [] + + class TestApp(App): + def on_key(self, event: events.Key) -> None: + keys_pressed.append(event.key) + + async def auto_pilot(pilot: Pilot) -> None: + + await pilot.press("tab", *"foo") + await pilot.pause(1 / 100) + await pilot.exit("bar") + + app = TestApp() + result = app.run(headless=True, auto_pilot=auto_pilot) + assert result == "bar" + assert keys_pressed == ["tab", "f", "o", "o"] diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py new file mode 100644 index 000000000..515f87341 --- /dev/null +++ b/tests/test_test_runner.py @@ -0,0 +1,21 @@ +from textual.app import App +from textual import events + + +async def test_run_test() -> None: + """Test the run_test context manager.""" + keys_pressed: list[str] = [] + + class TestApp(App[str]): + def on_key(self, event: events.Key) -> None: + keys_pressed.append(event.key) + + app = TestApp() + async with app.run_test() as pilot: + assert str(pilot) == "" + await pilot.press("tab", *"foo") + await pilot.pause(1 / 100) + await pilot.exit("bar") + + assert app.return_value == "bar" + assert keys_pressed == ["tab", "f", "o", "o"] From deb00c21e885bda47625eab72b4ce37c175b6e02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 13:44:05 +0100 Subject: [PATCH 09/20] added pilot to docs --- docs/reference/pilot.md | 1 + mkdocs.yml | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/reference/pilot.md diff --git a/docs/reference/pilot.md b/docs/reference/pilot.md new file mode 100644 index 000000000..e1db65812 --- /dev/null +++ b/docs/reference/pilot.md @@ -0,0 +1 @@ +::: textual.pilot diff --git a/mkdocs.yml b/mkdocs.yml index 78e6c97b3..ffc3f0f82 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - "reference/index.md" - "reference/message_pump.md" - "reference/message.md" + - "reference/pilot.md" - "reference/query.md" - "reference/reactive.md" - "reference/screen.md" From 5b9bb575f02f020a180fde4474b24238cc1bb9db Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 14:46:15 +0100 Subject: [PATCH 10/20] words --- src/textual/app.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 14b46c1aa..294ebee42 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -107,7 +107,6 @@ ComposeResult = Iterable[Widget] RenderResult = RenderableType -# AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Awaitable[None]]" AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" @@ -1117,7 +1116,7 @@ class App(Generic[ReturnType], DOMNode): self.log.system("[b green]STARTED[/]", self.css_monitor) async def run_process_messages(): - """The main message look, invoke below.""" + """The main message loop, invoke below.""" try: await self._dispatch_message(events.Compose(sender=self)) await self._dispatch_message(events.Mount(sender=self)) @@ -1186,11 +1185,6 @@ class App(Generic[ReturnType], DOMNode): driver.stop_application_mode() except Exception as error: self._handle_exception(error) - # finally: - # self._running = False - # self._print_error_renderables() - # if self.devtools is not None and self.devtools.is_connected: - # await self._disconnect_devtools() async def _pre_process(self) -> None: pass @@ -1320,7 +1314,10 @@ class App(Generic[ReturnType], DOMNode): # Close all screens on the stack for screen in self._screen_stack: - await self._prune_node(screen) + if screen._running: + await self._prune_node(screen) + + self._screen_stack.clear() # Close pre-defined screens for screen in self.SCREENS.values(): From 9f88f9e3ebf44572e75a91fa0ffe95f9dec59c12 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 19:55:54 +0100 Subject: [PATCH 11/20] remove sandbox --- sandbox/will/dictionary.css | 26 ----------- sandbox/will/dictionary.py | 82 ----------------------------------- sandbox/will/pride.py | 29 ------------- sandbox/will/scroll_remove.py | 18 -------- 4 files changed, 155 deletions(-) delete mode 100644 sandbox/will/dictionary.css delete mode 100644 sandbox/will/dictionary.py delete mode 100644 sandbox/will/pride.py delete mode 100644 sandbox/will/scroll_remove.py diff --git a/sandbox/will/dictionary.css b/sandbox/will/dictionary.css deleted file mode 100644 index 6bca8b9f5..000000000 --- a/sandbox/will/dictionary.css +++ /dev/null @@ -1,26 +0,0 @@ -Screen { - background: $panel; -} - -Input { - dock: top; - margin: 1 0; -} - -#results { - width: auto; - min-height: 100%; - padding: 0 1; -} - -#results-container { - background: $background 50%; - margin: 0 0 1 0; - height: 100%; - overflow: hidden auto; - border: tall $background; -} - -#results-container:focus { - border: tall $accent; -} diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py deleted file mode 100644 index 5bb6d8e69..000000000 --- a/sandbox/will/dictionary.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import asyncio - -try: - import httpx -except ImportError: - raise ImportError("Please install httpx with 'pip install httpx' ") - -from rich.markdown import Markdown - -from textual.app import App, ComposeResult -from textual.containers import Content -from textual.widgets import Static, Input - - -class DictionaryApp(App): - """Searches ab dictionary API as-you-type.""" - - CSS_PATH = "dictionary.css" - - def compose(self) -> ComposeResult: - yield Input(placeholder="Search for a word") - yield Content(Static(id="results"), id="results-container") - - def on_mount(self) -> None: - """Called when app starts.""" - # Give the input focus, so we can start typing straight away - self.query_one(Input).focus() - - async def on_input_changed(self, message: Input.Changed) -> None: - """A coroutine to handle a text changed message.""" - if message.value: - # Look up the word in the background - asyncio.create_task(self.lookup_word(message.value)) - else: - # Clear the results - self.query_one("#results", Static).update() - - async def lookup_word(self, word: str) -> None: - """Looks up a word.""" - url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" - async with httpx.AsyncClient() as client: - results = (await client.get(url)).json() - - if word == self.query_one(Input).value: - markdown = self.make_word_markdown(results) - self.query_one("#results", Static).update(Markdown(markdown)) - - def make_word_markdown(self, results: object) -> str: - """Convert the results in to markdown.""" - lines = [] - if isinstance(results, dict): - lines.append(f"# {results['title']}") - lines.append(results["message"]) - elif isinstance(results, list): - for result in results: - lines.append(f"# {result['word']}") - lines.append("") - for meaning in result.get("meanings", []): - lines.append(f"_{meaning['partOfSpeech']}_") - lines.append("") - for definition in meaning.get("definitions", []): - lines.append(f" - {definition['definition']}") - lines.append("---") - - return "\n".join(lines) - - -if __name__ == "__main__": - app = DictionaryApp() - - from textual.pilot import Pilot - - async def auto_pilot(pilot: Pilot) -> None: - await pilot.press(*"Hello") - await pilot.pause(2) - await pilot.press(*" World!") - await pilot.pause(3) - pilot.app.exit() - - app.run(auto_pilot=auto_pilot) diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py deleted file mode 100644 index 788f6be03..000000000 --- a/sandbox/will/pride.py +++ /dev/null @@ -1,29 +0,0 @@ -from textual.app import App, ComposeResult -from textual.widgets import Static - - -class PrideApp(App): - """Displays a pride flag.""" - - COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] - - def compose(self) -> ComposeResult: - for color in self.COLORS: - stripe = Static() - stripe.styles.height = "1fr" - stripe.styles.background = color - yield stripe - - -if __name__ == "__main__": - app = PrideApp() - - from rich import print - - async def run_app(): - async with app.run_managed() as pilot: - await pilot.pause(5) - - import asyncio - - asyncio.run(run_app()) diff --git a/sandbox/will/scroll_remove.py b/sandbox/will/scroll_remove.py deleted file mode 100644 index abad13947..000000000 --- a/sandbox/will/scroll_remove.py +++ /dev/null @@ -1,18 +0,0 @@ -from textual.app import App, ComposeResult - -from textual.containers import Container - - -class ScrollApp(App): - - def compose(self) -> ComposeResult: - yield Container( - Container(), Container(), - id="top") - - def key_r(self) -> None: - self.query_one("#top").remove() - -if __name__ == "__main__": - app = ScrollApp() - app.run() From 2092d42bbf323e35e51bde7aa6e2ea78c9ed1425 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 20:21:25 +0100 Subject: [PATCH 12/20] more typing --- src/textual/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 294ebee42..42653aa34 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -691,7 +691,9 @@ class App(Generic[ReturnType], DOMNode): nonlocal auto_pilot_task if auto_pilot is not None: - async def run_auto_pilot(pilot) -> None: + async def run_auto_pilot( + auto_pilot: AutopilotCallbackType, pilot: Pilot + ) -> None: try: await auto_pilot(pilot) except Exception: @@ -699,7 +701,7 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - auto_pilot_task = asyncio.create_task(run_auto_pilot(pilot)) + auto_pilot_task = asyncio.create_task(run_auto_pilot(auto_pilot, pilot)) try: await app._process_messages( From c5b2e6982e1834a9cb72e51e052d5520a1a066fe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:09:16 +0100 Subject: [PATCH 13/20] pass size as a parameter --- src/textual/_doc.py | 14 ++++++------- src/textual/app.py | 28 ++++++++++++++++++++------ src/textual/driver.py | 8 +++++++- src/textual/drivers/headless_driver.py | 2 ++ src/textual/drivers/linux_driver.py | 9 +++++++-- src/textual/drivers/windows_driver.py | 9 +++++++-- tests/snapshot_tests/conftest.py | 4 ++-- 7 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index b3021c4fc..32a074625 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -30,7 +30,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str rows = int(attrs.get("lines", 24)) columns = int(attrs.get("columns", 80)) svg = take_svg_screenshot( - None, path, press, title, terminal_size=(rows, columns) + None, path, press, title, terminal_size=(columns, rows) ) finally: os.chdir(cwd) @@ -49,7 +49,7 @@ def take_svg_screenshot( app_path: str | None = None, press: Iterable[str] = ("_",), title: str | None = None, - terminal_size: tuple[int, int] = (24, 80), + terminal_size: tuple[int, int] = (80, 24), ) -> str: """ @@ -65,10 +65,6 @@ def take_svg_screenshot( the screenshot was taken. """ - rows, columns = terminal_size - - os.environ["COLUMNS"] = str(columns) - os.environ["LINES"] = str(rows) if app is None: assert app_path is not None @@ -85,7 +81,11 @@ def take_svg_screenshot( svg = app.export_screenshot(title=title) app.exit(svg) - svg = app.run(headless=True, auto_pilot=auto_pilot) + svg = app.run( + headless=True, + auto_pilot=auto_pilot, + size=terminal_size, + ) assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index 42653aa34..c2be19198 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -444,7 +444,11 @@ class App(Generic[ReturnType], DOMNode): Returns: Size: Size of the terminal """ - return Size(*self.console.size) + if self._driver is not None and self._driver._size is not None: + width, height = self._driver._size + else: + width, height = self.console.size + return Size(width, height) @property def log(self) -> Logger: @@ -526,10 +530,11 @@ class App(Generic[ReturnType], DOMNode): to use app title. Defaults to None. """ - + assert self._driver is not None, "App must be running" + width, height = self.size console = Console( - width=self.console.width, - height=self.console.height, + width=width, + height=height, file=io.StringIO(), force_terminal=True, color_system="truecolor", @@ -669,12 +674,15 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, + size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app asynchronously. Args: headless (bool, optional): Run in headless mode (no output). Defaults to False. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: @@ -707,6 +715,7 @@ class App(Generic[ReturnType], DOMNode): await app._process_messages( ready_callback=None if auto_pilot is None else app_ready, headless=headless, + terminal_size=size, ) finally: if auto_pilot_task is not None: @@ -719,12 +728,15 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, + size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app. Args: headless (bool, optional): Run in headless mode (no output). Defaults to False. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: @@ -735,6 +747,7 @@ class App(Generic[ReturnType], DOMNode): """Run the app.""" await self.run_async( headless=headless, + size=size, auto_pilot=auto_pilot, ) @@ -1072,7 +1085,10 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.clear() async def _process_messages( - self, ready_callback: CallbackType | None = None, headless: bool = False + self, + ready_callback: CallbackType | None = None, + headless: bool = False, + terminal_size: tuple[int, int] | None = None, ) -> None: self._set_active() @@ -1161,7 +1177,7 @@ class App(Generic[ReturnType], DOMNode): "type[Driver]", HeadlessDriver if headless else self.driver_class, ) - driver = self._driver = driver_class(self.console, self) + driver = self._driver = driver_class(self.console, self, size=terminal_size) driver.start_application_mode() try: diff --git a/src/textual/driver.py b/src/textual/driver.py index 0eb3767c3..5e470b697 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -13,11 +13,17 @@ if TYPE_CHECKING: class Driver(ABC): def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: self.console = console self._target = target self._debug = debug + self._size = size self._loop = asyncio.get_running_loop() self._mouse_down_time = _clock.get_time_no_wait() diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py index 15c31a9ae..87a0940e7 100644 --- a/src/textual/drivers/headless_driver.py +++ b/src/textual/drivers/headless_driver.py @@ -14,6 +14,8 @@ class HeadlessDriver(Driver): return True def _get_terminal_size(self) -> tuple[int, int]: + if self._size is not None: + return self._size width: int | None = 80 height: int | None = 25 import shutil diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index e8c7bd00a..f0e75e71e 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -30,9 +30,14 @@ class LinuxDriver(Driver): """Powers display and input for Linux / MacOS""" def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: - super().__init__(console, target, debug) + super().__init__(console, target, debug=debug, size=size) self.fileno = sys.stdin.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index fb51973ea..b14af7ab5 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -18,9 +18,14 @@ class WindowsDriver(Driver): """Powers display and input for Windows.""" def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: - super().__init__(console, target, debug) + super().__init__(console, target, debug=debug, size=size) self.in_fileno = sys.stdin.fileno() self.out_fileno = sys.stdout.fileno() diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index 5a5862df7..a399c5533 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -41,7 +41,7 @@ def snap_compare( def compare( app_path: str, press: Iterable[str] = ("_",), - terminal_size: tuple[int, int] = (24, 80), + terminal_size: tuple[int, int] = (80, 24), ) -> bool: """ Compare a current screenshot of the app running at app_path, with @@ -52,7 +52,7 @@ def snap_compare( Args: app_path (str): The path of the app. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. - terminal_size (tuple[int, int]): A pair of integers (rows, columns), representing terminal size. + terminal_size (tuple[int, int]): A pair of integers (WIDTH, SIZE), representing terminal size. Returns: bool: True if the screenshot matches the snapshot. From dcbe88833f3e0c4fd947d5c76a1d6683a657665d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:14:01 +0100 Subject: [PATCH 14/20] posssible speedup of screenshots --- src/textual/_doc.py | 2 +- src/textual/pilot.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 32a074625..d7a531261 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -47,7 +47,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str def take_svg_screenshot( app: App | None = None, app_path: str | None = None, - press: Iterable[str] = ("_",), + press: Iterable[str] = (), title: str | None = None, terminal_size: tuple[int, int] = (80, 24), ) -> str: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 58b2743d1..6f3f046a4 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -35,7 +35,8 @@ class Pilot: *key: Keys to press. """ - await self._app._press_keys(keys) + if keys: + await self._app._press_keys(keys) async def pause(self, delay: float = 50 / 1000) -> None: """Insert a pause. From 6b075399a060a0e90287096a25f394ff5996733f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:16:22 +0100 Subject: [PATCH 15/20] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d605f010..69b41ae86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added App.run_test context manager - Added auto_pilot to App.run and App.run_async - Added Widget._get_virtual_dom to get scrollbars +- Added size parameter to run and run_async ## [0.2.1] - 2022-10-23 From f95a61c11540ea244268083cdac5591f9175720e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:30:02 +0100 Subject: [PATCH 16/20] remove import --- src/textual/_doc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index d7a531261..36dc4c255 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import os import shlex from typing import Iterable From 02658dedd2c8f1b9c164eadef1f5670689c9173c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:32:20 +0100 Subject: [PATCH 17/20] remove delay --- src/textual/_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 36dc4c255..2d6bce67b 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -19,7 +19,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str path = cmd[0] _press = attrs.get("press", None) - press = [*_press.split(",")] if _press else ["_"] + press = [*_press.split(",")] if _press else [] title = attrs.get("title") print(f"screenshotting {path!r}") From cfd5d532dd6877ad79064cbbe65a0caff5ad561e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 30 Oct 2022 08:43:23 +0000 Subject: [PATCH 18/20] test for unmount --- src/textual/app.py | 55 ++++++++++++++++++++++++------------- src/textual/events.py | 2 +- src/textual/message_pump.py | 2 +- tests/test_unmount.py | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 tests/test_unmount.py diff --git a/src/textual/app.py b/src/textual/app.py index c2be19198..bbc30baa7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -638,11 +638,18 @@ class App(Generic[ReturnType], DOMNode): await app._animator.wait_for_idle() @asynccontextmanager - async def run_test(self, *, headless: bool = True): + async def run_test( + self, + *, + headless: bool = True, + size: tuple[int, int] | None = (80, 24), + ): """An asynchronous context manager for testing app. Args: headless (bool, optional): Run in headless mode (no output or input). Defaults to True. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. """ from .pilot import Pilot @@ -655,7 +662,11 @@ class App(Generic[ReturnType], DOMNode): app_ready_event.set() async def run_app(app) -> None: - await app._process_messages(ready_callback=on_app_ready, headless=headless) + await app._process_messages( + ready_callback=on_app_ready, + headless=headless, + terminal_size=size, + ) # Launch the app in the "background" app_task = asyncio.create_task(run_app(app)) @@ -1135,24 +1146,30 @@ class App(Generic[ReturnType], DOMNode): async def run_process_messages(): """The main message loop, invoke below.""" + + async def invoke_ready_callback() -> None: + if ready_callback is not None: + ready_result = ready_callback() + if inspect.isawaitable(ready_result): + await ready_result + try: - await self._dispatch_message(events.Compose(sender=self)) - await self._dispatch_message(events.Mount(sender=self)) + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + self._mounted_event.set() + + Reactive._initialize_object(self) + + self.stylesheet.update(self) + self.refresh() + + await self.animator.start() + finally: - self._mounted_event.set() - - Reactive._initialize_object(self) - - self.stylesheet.update(self) - self.refresh() - - await self.animator.start() - await self._ready() - - if ready_callback is not None: - ready_result = ready_callback() - if inspect.isawaitable(ready_result): - await ready_result + await self._ready() + await invoke_ready_callback() self._running = True @@ -1356,7 +1373,7 @@ class App(Generic[ReturnType], DOMNode): await self._close_all() await self._close_messages() - await self._dispatch_message(events.UnMount(sender=self)) + await self._dispatch_message(events.Unmount(sender=self)) self._print_error_renderables() if self.devtools is not None and self.devtools.is_connected: diff --git a/src/textual/events.py b/src/textual/events.py index 7a88af264..046ded120 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -123,7 +123,7 @@ class Mount(Event, bubble=False, verbose=False): """Sent when a widget is *mounted* and may receive messages.""" -class UnMount(Mount, bubble=False, verbose=False): +class Unmount(Mount, bubble=False, verbose=False): """Sent when a widget is unmounted and may not longer receive messages.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c0c1e6160..4ede28b75 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -283,7 +283,7 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in stop_timers: await timer.stop() self._timers.clear() - await self._message_queue.put(events.UnMount(sender=self)) + await self._message_queue.put(events.Unmount(sender=self)) await self._message_queue.put(None) if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning diff --git a/tests/test_unmount.py b/tests/test_unmount.py new file mode 100644 index 000000000..632998d5b --- /dev/null +++ b/tests/test_unmount.py @@ -0,0 +1,50 @@ +from textual.app import App, ComposeResult +from textual import events +from textual.containers import Container +from textual.screen import Screen + + +async def test_unmount(): + """Text unmount events are received in reverse DOM order.""" + unmount_ids: list[str] = [] + + class UnmountWidget(Container): + def on_unmount(self, event: events.Unmount): + unmount_ids.append(f"{self.__class__.__name__}#{self.id}") + + class MyScreen(Screen): + def compose(self) -> ComposeResult: + yield UnmountWidget( + UnmountWidget( + UnmountWidget(id="bar1"), UnmountWidget(id="bar2"), id="bar" + ), + UnmountWidget( + UnmountWidget(id="baz1"), UnmountWidget(id="baz2"), id="baz" + ), + id="top", + ) + + def on_unmount(self, event: events.Unmount): + unmount_ids.append(f"{self.__class__.__name__}#{self.id}") + + class UnmountApp(App): + async def on_mount(self) -> None: + self.push_screen(MyScreen(id="main")) + + app = UnmountApp() + async with app.run_test() as pilot: + await pilot.pause() # TODO remove when push_screen is awaitable + await pilot.exit(None) + + expected = [ + "UnmountWidget#bar1", + "UnmountWidget#bar2", + "UnmountWidget#baz1", + "UnmountWidget#baz2", + "UnmountWidget#bar", + "UnmountWidget#baz", + "UnmountWidget#top", + "MyScreen#main", + ] + + assert unmount_ids == expected From 4bce8317c76d9178ff7dc39423a0b2593dbb9ae0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 13:28:57 +0000 Subject: [PATCH 19/20] Update src/textual/app.py Co-authored-by: darrenburns --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index bbc30baa7..83fc64110 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1644,7 +1644,7 @@ class App(Generic[ReturnType], DOMNode): """Walk children depth first, generating widgets and a list of their siblings. Returns: - Iterable[list[Widget]]: + Iterable[list[Widget]]: The child widgets of root. """ stack: list[Widget] = [root] From 07dced3435126125e5c579bb16c537169f8a3e18 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 13:31:10 +0000 Subject: [PATCH 20/20] Update tests/test_unmount.py Co-authored-by: darrenburns --- tests/test_unmount.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 632998d5b..e5c557aa0 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -5,7 +5,7 @@ from textual.screen import Screen async def test_unmount(): - """Text unmount events are received in reverse DOM order.""" + """Test unmount events are received in reverse DOM order.""" unmount_ids: list[str] = [] class UnmountWidget(Container):