Skip to content

Commit

Permalink
Add loading reactive (Textualize#3509)
Browse files Browse the repository at this point in the history
* Add loading reactive

* loading indicator example

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <[email protected]>

* into

* changelog

---------

Co-authored-by: Rodrigo Girão Serrão <[email protected]>
  • Loading branch information
willmcgugan and rodrigogiraoserrao authored Oct 11, 2023
1 parent 5cf0e1a commit 47af5e0
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 4 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions docs/examples/guide/widgets/loading01.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 33 additions & 2 deletions docs/guide/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -533,7 +564,7 @@ Here's a sketch of what the app should ultimately look like:
--8<-- "docs/images/byte01.excalidraw.svg"
</div>

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"

Expand Down Expand Up @@ -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:

Expand Down
26 changes: 26 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from types import TracebackType
from typing import (
TYPE_CHECKING,
Awaitable,
ClassVar,
Collection,
Generator,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion src/textual/widgets/_loading_indicator.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

0 comments on commit 47af5e0

Please sign in to comment.