Skip to content

Commit 47af5e0

Browse files
Add loading reactive (#3509)
* 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]>
1 parent 5cf0e1a commit 47af5e0

File tree

6 files changed

+191
-4
lines changed

6 files changed

+191
-4
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [0.40.0] - 2023-10-11
9+
10+
- Added `loading` reactive property to widgets
11+
812
## [0.39.0] - 2023-10-10
913

1014
### Fixed
@@ -1342,6 +1346,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
13421346
- New handler system for messages that doesn't require inheritance
13431347
- Improved traceback handling
13441348

1349+
[0.40.0]: https://github.com/Textualize/textual/compare/v0.39.0...v0.40.0
13451350
[0.39.0]: https://github.com/Textualize/textual/compare/v0.38.1...v0.39.0
13461351
[0.38.1]: https://github.com/Textualize/textual/compare/v0.38.0...v0.38.1
13471352
[0.38.0]: https://github.com/Textualize/textual/compare/v0.37.1...v0.38.0
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from asyncio import sleep
2+
from random import randint
3+
4+
from textual import work
5+
from textual.app import App, ComposeResult
6+
from textual.widgets import DataTable
7+
8+
ROWS = [
9+
("lane", "swimmer", "country", "time"),
10+
(4, "Joseph Schooling", "Singapore", 50.39),
11+
(2, "Michael Phelps", "United States", 51.14),
12+
(5, "Chad le Clos", "South Africa", 51.14),
13+
(6, "László Cseh", "Hungary", 51.14),
14+
(3, "Li Zhuhao", "China", 51.26),
15+
(8, "Mehdy Metella", "France", 51.58),
16+
(7, "Tom Shields", "United States", 51.73),
17+
(1, "Aleksandr Sadovnikov", "Russia", 51.84),
18+
(10, "Darren Burns", "Scotland", 51.84),
19+
]
20+
21+
22+
class DataApp(App):
23+
CSS = """
24+
Screen {
25+
layout: grid;
26+
grid-size: 2;
27+
}
28+
DataTable {
29+
height: 1fr;
30+
}
31+
"""
32+
33+
def compose(self) -> ComposeResult:
34+
yield DataTable()
35+
yield DataTable()
36+
yield DataTable()
37+
yield DataTable()
38+
39+
def on_mount(self) -> None:
40+
for data_table in self.query(DataTable):
41+
data_table.loading = True # (1)!
42+
self.load_data(data_table)
43+
44+
@work
45+
async def load_data(self, data_table: DataTable) -> None:
46+
await sleep(randint(2, 10)) # (2)!
47+
data_table.add_columns(*ROWS[0])
48+
data_table.add_rows(ROWS[1:])
49+
data_table.loading = False # (3)!
50+
51+
52+
if __name__ == "__main__":
53+
app = DataApp()
54+
app.run()

docs/guide/widgets.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,37 @@ Add a rule to your CSS that targets `Tooltip`. Here's an example:
294294
```{.textual path="docs/examples/guide/widgets/tooltip02.py" hover="Button"}
295295
```
296296

297+
## Loading indicator
298+
299+
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).
300+
301+
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.
302+
Let's look at an example of this.
303+
304+
=== "loading01.py"
305+
306+
```python title="loading01.py"
307+
--8<-- "docs/examples/guide/widgets/loading01.py"
308+
```
309+
310+
1. Shows the loading indicator in place of the data table.
311+
2. Insert a random sleep to simulate a network request.
312+
3. Show the new data.
313+
314+
=== "Output"
315+
316+
```{.textual path="docs/examples/guide/widgets/loading01.py"}
317+
```
318+
319+
320+
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`.
321+
This will temporarily replace the widget with a loading indicator animation.
322+
When the (simulated) data has been retrieved, we reset the `loading` property to show the new data.
323+
324+
!!! tip
325+
326+
See the guide on [Workers](./workers.md) if you want to know more about the `@work` decorator.
327+
297328
## Line API
298329

299330
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:
533564
--8<-- "docs/images/byte01.excalidraw.svg"
534565
</div>
535566

536-
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.
567+
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.
537568

538569
??? textualize "Try in Textual-web"
539570

@@ -574,7 +605,7 @@ Note the `compose()` methods of each of the widgets.
574605

575606
- 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.
576607

577-
- 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.
608+
- 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.
578609

579610
With these three widgets, the [DOM](CSS.md#the-dom) for our app will look like this:
580611

src/textual/widget.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from types import TracebackType
1313
from typing import (
1414
TYPE_CHECKING,
15+
Awaitable,
1516
ClassVar,
1617
Collection,
1718
Generator,
@@ -278,6 +279,8 @@ class Widget(DOMNode):
278279
"""The current hover style (style under the mouse cursor). Read only."""
279280
highlight_link_id: Reactive[str] = Reactive("")
280281
"""The currently highlighted link id. Read only."""
282+
loading: Reactive[bool] = Reactive(False)
283+
"""If set to `True` this widget will temporarily be replaced with a loading indicator."""
281284

282285
def __init__(
283286
self,
@@ -497,6 +500,29 @@ def __exit__(
497500
else:
498501
self.app._composed[-1].append(composed)
499502

503+
def set_loading(self, loading: bool) -> Awaitable:
504+
"""Set or reset the loading state of this widget.
505+
506+
A widget in a loading state will display a LoadingIndicator that obscures the widget.
507+
508+
Args:
509+
loading: `True` to put the widget into a loading state, or `False` to reset the loading state.
510+
511+
Returns:
512+
An optional awaitable.
513+
"""
514+
from textual.widgets import LoadingIndicator
515+
516+
if loading:
517+
loading_indicator = LoadingIndicator()
518+
return loading_indicator.apply(self)
519+
else:
520+
return LoadingIndicator.clear(self)
521+
522+
async def _watch_loading(self, loading: bool) -> None:
523+
"""Called when the 'loading' reactive is changed."""
524+
await self.set_loading(loading)
525+
500526
ExpectType = TypeVar("ExpectType", bound="Widget")
501527

502528
@overload

src/textual/widgets/_loading_indicator.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from __future__ import annotations
22

33
from time import time
4+
from typing import Awaitable
45

56
from rich.console import RenderableType
67
from rich.style import Style
78
from rich.text import Text
89

910
from ..color import Gradient
11+
from ..css.query import NoMatches
1012
from ..events import Mount
11-
from ..widget import Widget
13+
from ..widget import AwaitMount, Widget
1214

1315

1416
class LoadingIndicator(Widget):
@@ -22,8 +24,49 @@ class LoadingIndicator(Widget):
2224
content-align: center middle;
2325
color: $accent;
2426
}
27+
LoadingIndicator.-overlay {
28+
overlay: screen;
29+
background: $boost;
30+
}
2531
"""
2632

33+
def apply(self, widget: Widget) -> AwaitMount:
34+
"""Apply the loading indicator to a `widget`.
35+
36+
This will overlay the given widget with a loading indicator.
37+
38+
Args:
39+
widget: A widget.
40+
41+
Returns:
42+
AwaitMount: An awaitable for mounting the indicator.
43+
"""
44+
self.add_class("-overlay")
45+
await_mount = widget.mount(self, before=0)
46+
return await_mount
47+
48+
@classmethod
49+
def clear(cls, widget: Widget) -> Awaitable:
50+
"""Clear any loading indicator from the given widget.
51+
52+
Args:
53+
widget: Widget to clear the loading indicator from.
54+
55+
Returns:
56+
Optional awaitable.
57+
"""
58+
try:
59+
await_remove = widget.get_child_by_type(cls).remove()
60+
except NoMatches:
61+
62+
async def null() -> None:
63+
"""Nothing to remove"""
64+
return None
65+
66+
return null()
67+
68+
return await_remove
69+
2770
def _on_mount(self, _: Mount) -> None:
2871
self._start_time = time()
2972
self.auto_refresh = 1 / 16

tests/test_widget.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from textual.geometry import Offset, Size
1111
from textual.message import Message
1212
from textual.widget import MountError, PseudoClasses, Widget
13-
from textual.widgets import Label
13+
from textual.widgets import Label, LoadingIndicator
1414

1515

1616
@pytest.mark.parametrize(
@@ -355,3 +355,31 @@ def test_get_set_tooltip():
355355
assert widget.tooltip == "This is a tooltip."
356356

357357

358+
async def test_loading():
359+
"""Test setting the loading reactive."""
360+
361+
class LoadingApp(App):
362+
def compose(self) -> ComposeResult:
363+
yield Label("Hello, World")
364+
365+
async with LoadingApp().run_test() as pilot:
366+
app = pilot.app
367+
label = app.query_one(Label)
368+
assert label.loading == False
369+
assert len(label.query(LoadingIndicator)) == 0
370+
371+
label.loading = True
372+
await pilot.pause()
373+
assert len(label.query(LoadingIndicator)) == 1
374+
375+
label.loading = True # Setting to same value is a null-op
376+
await pilot.pause()
377+
assert len(label.query(LoadingIndicator)) == 1
378+
379+
label.loading = False
380+
await pilot.pause()
381+
assert len(label.query(LoadingIndicator)) == 0
382+
383+
label.loading = False # Setting to same value is a null-op
384+
await pilot.pause()
385+
assert len(label.query(LoadingIndicator)) == 0

0 commit comments

Comments
 (0)