From 47af5e04022b7aa1808ee3b852a85b5666322adb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 11 Oct 2023 16:53:49 +0100 Subject: [PATCH] Add loading reactive (#3509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add loading reactive * loading indicator example * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * into * changelog --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- CHANGELOG.md | 5 +++ docs/examples/guide/widgets/loading01.py | 54 +++++++++++++++++++++++ docs/guide/widgets.md | 35 ++++++++++++++- src/textual/widget.py | 26 +++++++++++ src/textual/widgets/_loading_indicator.py | 45 ++++++++++++++++++- tests/test_widget.py | 30 ++++++++++++- 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 docs/examples/guide/widgets/loading01.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61dc1f9975..7b63f0b22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.40.0] - 2023-10-11 + +- Added `loading` reactive property to widgets + ## [0.39.0] - 2023-10-10 ### Fixed @@ -1342,6 +1346,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.40.0]: https://github.com/Textualize/textual/compare/v0.39.0...v0.40.0 [0.39.0]: https://github.com/Textualize/textual/compare/v0.38.1...v0.39.0 [0.38.1]: https://github.com/Textualize/textual/compare/v0.38.0...v0.38.1 [0.38.0]: https://github.com/Textualize/textual/compare/v0.37.1...v0.38.0 diff --git a/docs/examples/guide/widgets/loading01.py b/docs/examples/guide/widgets/loading01.py new file mode 100644 index 0000000000..3e25899cbe --- /dev/null +++ b/docs/examples/guide/widgets/loading01.py @@ -0,0 +1,54 @@ +from asyncio import sleep +from random import randint + +from textual import work +from textual.app import App, ComposeResult +from textual.widgets import DataTable + +ROWS = [ + ("lane", "swimmer", "country", "time"), + (4, "Joseph Schooling", "Singapore", 50.39), + (2, "Michael Phelps", "United States", 51.14), + (5, "Chad le Clos", "South Africa", 51.14), + (6, "László Cseh", "Hungary", 51.14), + (3, "Li Zhuhao", "China", 51.26), + (8, "Mehdy Metella", "France", 51.58), + (7, "Tom Shields", "United States", 51.73), + (1, "Aleksandr Sadovnikov", "Russia", 51.84), + (10, "Darren Burns", "Scotland", 51.84), +] + + +class DataApp(App): + CSS = """ + Screen { + layout: grid; + grid-size: 2; + } + DataTable { + height: 1fr; + } + """ + + def compose(self) -> ComposeResult: + yield DataTable() + yield DataTable() + yield DataTable() + yield DataTable() + + def on_mount(self) -> None: + for data_table in self.query(DataTable): + data_table.loading = True # (1)! + self.load_data(data_table) + + @work + async def load_data(self, data_table: DataTable) -> None: + await sleep(randint(2, 10)) # (2)! + data_table.add_columns(*ROWS[0]) + data_table.add_rows(ROWS[1:]) + data_table.loading = False # (3)! + + +if __name__ == "__main__": + app = DataApp() + app.run() diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index dc371c1eab..b39fa1999f 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -294,6 +294,37 @@ Add a rule to your CSS that targets `Tooltip`. Here's an example: ```{.textual path="docs/examples/guide/widgets/tooltip02.py" hover="Button"} ``` +## Loading indicator + +Widgets have a [`loading`][textual.widget.Widget.loading] reactive which when set to `True` will temporarily replace your widget with a [`LoadingIndicator`](../widgets/loading_indicator.md). + +You can use this to indicate to the user that the app is currently working on getting data, and there will be content when that data is available. +Let's look at an example of this. + +=== "loading01.py" + + ```python title="loading01.py" + --8<-- "docs/examples/guide/widgets/loading01.py" + ``` + + 1. Shows the loading indicator in place of the data table. + 2. Insert a random sleep to simulate a network request. + 3. Show the new data. + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/loading01.py"} + ``` + + +In this example we have four [DataTable](../widgets/data_table.md) widgets, which we put into a loading state by setting the widget's `loading` property to `True`. +This will temporarily replace the widget with a loading indicator animation. +When the (simulated) data has been retrieved, we reset the `loading` property to show the new data. + +!!! tip + + See the guide on [Workers](./workers.md) if you want to know more about the `@work` decorator. + ## Line API A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size. @@ -533,7 +564,7 @@ Here's a sketch of what the app should ultimately look like: --8<-- "docs/images/byte01.excalidraw.svg" -There are three types of built-in widget in the sketch, namely ([Input](../widgets/input.md), [Label](../widgets/label.md), and [Switch](../widgets/switch.md)). Rather than manage these as a single collection of widgets, we can arrange them in to logical groups with compound widgets. This will make our app easier to work with. +There are three types of built-in widget in the sketch, namely ([Input](../widgets/input.md), [Label](../widgets/label.md), and [Switch](../widgets/switch.md)). Rather than manage these as a single collection of widgets, we can arrange them into logical groups with compound widgets. This will make our app easier to work with. ??? textualize "Try in Textual-web" @@ -574,7 +605,7 @@ Note the `compose()` methods of each of the widgets. - The `ByteInput` yields 8 `BitSwitch` widgets and arranges them horizontally. It also adds a `focus-within` style in its CSS to draw an accent border when any of the switches are focused. -- The `ByteEditor` yields a `ByteInput` and an `Input` control. The default CSS stacks the two controls on top of each other to divide the screen in to two parts. +- The `ByteEditor` yields a `ByteInput` and an `Input` control. The default CSS stacks the two controls on top of each other to divide the screen into two parts. With these three widgets, the [DOM](CSS.md#the-dom) for our app will look like this: diff --git a/src/textual/widget.py b/src/textual/widget.py index bad70dcf09..0fb94ad2e1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -12,6 +12,7 @@ from types import TracebackType from typing import ( TYPE_CHECKING, + Awaitable, ClassVar, Collection, Generator, @@ -278,6 +279,8 @@ class Widget(DOMNode): """The current hover style (style under the mouse cursor). Read only.""" highlight_link_id: Reactive[str] = Reactive("") """The currently highlighted link id. Read only.""" + loading: Reactive[bool] = Reactive(False) + """If set to `True` this widget will temporarily be replaced with a loading indicator.""" def __init__( self, @@ -497,6 +500,29 @@ def __exit__( else: self.app._composed[-1].append(composed) + def set_loading(self, loading: bool) -> Awaitable: + """Set or reset the loading state of this widget. + + A widget in a loading state will display a LoadingIndicator that obscures the widget. + + Args: + loading: `True` to put the widget into a loading state, or `False` to reset the loading state. + + Returns: + An optional awaitable. + """ + from textual.widgets import LoadingIndicator + + if loading: + loading_indicator = LoadingIndicator() + return loading_indicator.apply(self) + else: + return LoadingIndicator.clear(self) + + async def _watch_loading(self, loading: bool) -> None: + """Called when the 'loading' reactive is changed.""" + await self.set_loading(loading) + ExpectType = TypeVar("ExpectType", bound="Widget") @overload diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index 51a391f8f9..bdae0c6a72 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -1,14 +1,16 @@ from __future__ import annotations from time import time +from typing import Awaitable from rich.console import RenderableType from rich.style import Style from rich.text import Text from ..color import Gradient +from ..css.query import NoMatches from ..events import Mount -from ..widget import Widget +from ..widget import AwaitMount, Widget class LoadingIndicator(Widget): @@ -22,8 +24,49 @@ class LoadingIndicator(Widget): content-align: center middle; color: $accent; } + LoadingIndicator.-overlay { + overlay: screen; + background: $boost; + } """ + def apply(self, widget: Widget) -> AwaitMount: + """Apply the loading indicator to a `widget`. + + This will overlay the given widget with a loading indicator. + + Args: + widget: A widget. + + Returns: + AwaitMount: An awaitable for mounting the indicator. + """ + self.add_class("-overlay") + await_mount = widget.mount(self, before=0) + return await_mount + + @classmethod + def clear(cls, widget: Widget) -> Awaitable: + """Clear any loading indicator from the given widget. + + Args: + widget: Widget to clear the loading indicator from. + + Returns: + Optional awaitable. + """ + try: + await_remove = widget.get_child_by_type(cls).remove() + except NoMatches: + + async def null() -> None: + """Nothing to remove""" + return None + + return null() + + return await_remove + def _on_mount(self, _: Mount) -> None: self._start_time = time() self.auto_refresh = 1 / 16 diff --git a/tests/test_widget.py b/tests/test_widget.py index fb5fdb52e1..4b0058aafe 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -10,7 +10,7 @@ from textual.geometry import Offset, Size from textual.message import Message from textual.widget import MountError, PseudoClasses, Widget -from textual.widgets import Label +from textual.widgets import Label, LoadingIndicator @pytest.mark.parametrize( @@ -355,3 +355,31 @@ def test_get_set_tooltip(): assert widget.tooltip == "This is a tooltip." +async def test_loading(): + """Test setting the loading reactive.""" + + class LoadingApp(App): + def compose(self) -> ComposeResult: + yield Label("Hello, World") + + async with LoadingApp().run_test() as pilot: + app = pilot.app + label = app.query_one(Label) + assert label.loading == False + assert len(label.query(LoadingIndicator)) == 0 + + label.loading = True + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 1 + + label.loading = True # Setting to same value is a null-op + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 1 + + label.loading = False + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 0 + + label.loading = False # Setting to same value is a null-op + await pilot.pause() + assert len(label.query(LoadingIndicator)) == 0