Skip to content

Commit d6e2718

Browse files
authored
Merge pull request #5079 from Textualize/loading-redux
new loading indicator method
2 parents 91046dc + 13b5e32 commit d6e2718

File tree

6 files changed

+104
-64
lines changed

6 files changed

+104
-64
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1010
### Fixed
1111

1212
- Fixed issue with screen not updating when auto_refresh was enabled https://github.com/Textualize/textual/pull/5063
13+
- Fixed issues regarding loading indicator https://github.com/Textualize/textual/pull/5079
1314

1415
### Added
1516

1617
- Added `DOMNode.is_on_screen` property https://github.com/Textualize/textual/pull/5063
1718
- Added support for keymaps (user configurable key bindings) https://github.com/Textualize/textual/pull/5038
1819
- Added descriptions to bindings for all internal widgets, and updated casing to be consistent https://github.com/Textualize/textual/pull/5062
1920

21+
### Changed
22+
23+
- Breaking change: `Widget.set_loading` no longer return an awaitable https://github.com/Textualize/textual/pull/5079
24+
2025
## [0.81.0] - 2024-09-25
2126

2227
### Added

src/textual/_compositor.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,17 @@ def add_widget(
664664

665665
get_layer_index = layers_to_index.get
666666

667+
if widget._cover_widget is not None:
668+
map[widget._cover_widget] = _MapGeometry(
669+
region.shrink(widget.styles.gutter),
670+
order,
671+
clip,
672+
region.size,
673+
container_size,
674+
virtual_region,
675+
dock_gutter,
676+
)
677+
667678
# Add all the widgets
668679
for sub_region, _, sub_widget, z, fixed, overlay in reversed(
669680
placements
@@ -681,18 +692,17 @@ def add_widget(
681692
widget_region = self._constrain(
682693
sub_widget.styles, widget_region, no_clip
683694
)
684-
685-
add_widget(
686-
sub_widget,
687-
sub_region,
688-
widget_region,
689-
((1, 0, 0),) if overlay else widget_order,
690-
layer_order,
691-
no_clip if overlay else sub_clip,
692-
visible,
693-
arrange_result.scroll_spacing,
694-
)
695-
695+
if widget._cover_widget is None:
696+
add_widget(
697+
sub_widget,
698+
sub_region,
699+
widget_region,
700+
((1, 0, 0),) if overlay else widget_order,
701+
layer_order,
702+
no_clip if overlay else sub_clip,
703+
visible,
704+
arrange_result.scroll_spacing,
705+
)
696706
layer_order -= 1
697707

698708
if visible:
@@ -737,7 +747,7 @@ def add_widget(
737747
if styles.constrain != "none":
738748
widget_region = self._constrain(styles, widget_region, no_clip)
739749

740-
map[widget] = _MapGeometry(
750+
map[widget._render_widget] = _MapGeometry(
741751
widget_region,
742752
order,
743753
clip,

src/textual/widget.py

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from typing import (
1515
TYPE_CHECKING,
1616
AsyncGenerator,
17-
Awaitable,
1817
ClassVar,
1918
Collection,
2019
Generator,
@@ -58,7 +57,6 @@
5857
from textual._styles_cache import StylesCache
5958
from textual._types import AnimationLevel
6059
from textual.actions import SkipAction
61-
from textual.await_complete import AwaitComplete
6260
from textual.await_remove import AwaitRemove
6361
from textual.box_model import BoxModel
6462
from textual.cache import FIFOCache
@@ -333,6 +331,38 @@ class Widget(DOMNode):
333331
loading: Reactive[bool] = Reactive(False)
334332
"""If set to `True` this widget will temporarily be replaced with a loading indicator."""
335333

334+
virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
335+
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""
336+
337+
has_focus: Reactive[bool] = Reactive(False, repaint=False)
338+
"""Does this widget have focus? Read only."""
339+
340+
mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
341+
"""Is the mouse over this widget? Read only."""
342+
343+
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
344+
"""The scroll position on the X axis."""
345+
346+
scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
347+
"""The scroll position on the Y axis."""
348+
349+
scroll_target_x = Reactive(0.0, repaint=False)
350+
"""Scroll target destination, X coord."""
351+
352+
scroll_target_y = Reactive(0.0, repaint=False)
353+
"""Scroll target destination, Y coord."""
354+
355+
show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
356+
"""Show a vertical scrollbar?"""
357+
358+
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
359+
"""Show a horizontal scrollbar?"""
360+
361+
border_title: str | Text | None = _BorderTitle() # type: ignore
362+
"""A title to show in the top border (if there is one)."""
363+
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
364+
"""A title to show in the bottom border (if there is one)."""
365+
336366
# Default sort order, incremented by constructor
337367
_sort_order: ClassVar[int] = 0
338368

@@ -430,38 +460,8 @@ def __init__(
430460
"""An anchored child widget, or `None` if no child is anchored."""
431461
self._anchor_animate: bool = False
432462
"""Flag to enable animation when scrolling anchored widgets."""
433-
434-
virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
435-
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""
436-
437-
has_focus: Reactive[bool] = Reactive(False, repaint=False)
438-
"""Does this widget have focus? Read only."""
439-
440-
mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
441-
"""Is the mouse over this widget? Read only."""
442-
443-
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
444-
"""The scroll position on the X axis."""
445-
446-
scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
447-
"""The scroll position on the Y axis."""
448-
449-
scroll_target_x = Reactive(0.0, repaint=False)
450-
"""Scroll target destination, X coord."""
451-
452-
scroll_target_y = Reactive(0.0, repaint=False)
453-
"""Scroll target destination, Y coord."""
454-
455-
show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
456-
"""Show a vertical scrollbar?"""
457-
458-
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
459-
"""Show a horizontal scrollbar?"""
460-
461-
border_title: str | Text | None = _BorderTitle() # type: ignore
462-
"""A title to show in the top border (if there is one)."""
463-
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
464-
"""A title to show in the bottom border (if there is one)."""
463+
self._cover_widget: Widget | None = None
464+
"""Widget to render over this widget (used by loading indicator)."""
465465

466466
@property
467467
def is_mounted(self) -> bool:
@@ -587,6 +587,33 @@ def is_maximized(self) -> bool:
587587
except NoScreen:
588588
return False
589589

590+
@property
591+
def _render_widget(self) -> Widget:
592+
"""The widget the compositor should render."""
593+
# Will return the "cover widget" if one is set, otherwise self.
594+
return self._cover_widget if self._cover_widget is not None else self
595+
596+
def _cover(self, widget: Widget) -> None:
597+
"""Set a widget used to replace the visuals of this widget (used for loading indicator).
598+
599+
Args:
600+
widget: A newly constructed, but unmounted widget.
601+
"""
602+
self._uncover()
603+
self._cover_widget = widget
604+
widget._parent = self
605+
widget._start_messages()
606+
widget._post_register(self.app)
607+
self.app.stylesheet.apply(widget)
608+
self.refresh(layout=True)
609+
610+
def _uncover(self) -> None:
611+
"""Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover]."""
612+
if self._cover_widget is not None:
613+
self._cover_widget.remove()
614+
self._cover_widget = None
615+
self.refresh(layout=True)
616+
590617
def anchor(self, *, animate: bool = False) -> None:
591618
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
592619
but also keeps it in view if the widget's size changes, or the size of its container changes.
@@ -716,7 +743,7 @@ def get_loading_widget(self) -> Widget:
716743
loading_widget = self.app.get_loading_widget()
717744
return loading_widget
718745

719-
def set_loading(self, loading: bool) -> Awaitable:
746+
def set_loading(self, loading: bool) -> None:
720747
"""Set or reset the loading state of this widget.
721748
722749
A widget in a loading state will display a LoadingIndicator that obscures the widget.
@@ -728,19 +755,16 @@ def set_loading(self, loading: bool) -> Awaitable:
728755
An optional awaitable.
729756
"""
730757
LOADING_INDICATOR_CLASS = "-textual-loading-indicator"
731-
LOADING_INDICATOR_QUERY = f".{LOADING_INDICATOR_CLASS}"
732-
remove_indicator = self.query_children(LOADING_INDICATOR_QUERY).remove()
733758
if loading:
734759
loading_indicator = self.get_loading_widget()
735760
loading_indicator.add_class(LOADING_INDICATOR_CLASS)
736-
await_mount = self.mount(loading_indicator)
737-
return AwaitComplete(remove_indicator, await_mount).call_next(self)
761+
self._cover(loading_indicator)
738762
else:
739-
return remove_indicator
763+
self._uncover()
740764

741-
async def _watch_loading(self, loading: bool) -> None:
765+
def _watch_loading(self, loading: bool) -> None:
742766
"""Called when the 'loading' reactive is changed."""
743-
await self.set_loading(loading)
767+
self.set_loading(loading)
744768

745769
ExpectType = TypeVar("ExpectType", bound="Widget")
746770

@@ -3993,6 +4017,7 @@ def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
39934017
self.scroll_to_region(message.region, animate=True)
39944018

39954019
def _on_unmount(self) -> None:
4020+
self._uncover()
39964021
self.workers.cancel_node(self)
39974022

39984023
def action_scroll_home(self) -> None:

src/textual/widgets/_loading_indicator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class LoadingIndicator(Widget):
2323
min-height: 1;
2424
content-align: center middle;
2525
color: $accent;
26+
text-style: not reverse;
2627
}
2728
LoadingIndicator.-textual-loading-indicator {
2829
layer: _loading;

tests/animations/test_loading_indicator_animation.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"""
55

66
from textual.app import App
7-
from textual.widgets import LoadingIndicator
87

98

109
async def test_loading_indicator_is_not_static_on_full() -> None:
@@ -15,7 +14,7 @@ async def test_loading_indicator_is_not_static_on_full() -> None:
1514
async with app.run_test() as pilot:
1615
app.screen.loading = True
1716
await pilot.pause()
18-
indicator = app.query_one(LoadingIndicator)
17+
indicator = app.screen._cover_widget
1918
assert str(indicator.render()) != "Loading..."
2019

2120

@@ -27,7 +26,7 @@ async def test_loading_indicator_is_not_static_on_basic() -> None:
2726
async with app.run_test() as pilot:
2827
app.screen.loading = True
2928
await pilot.pause()
30-
indicator = app.query_one(LoadingIndicator)
29+
indicator = app.screen._cover_widget
3130
assert str(indicator.render()) != "Loading..."
3231

3332

@@ -39,5 +38,5 @@ async def test_loading_indicator_is_static_on_none() -> None:
3938
async with app.run_test() as pilot:
4039
app.screen.loading = True
4140
await pilot.pause()
42-
indicator = app.query_one(LoadingIndicator)
41+
indicator = app.screen._cover_widget
4342
assert str(indicator.render()) == "Loading..."

tests/test_widget.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -425,23 +425,23 @@ def compose(self) -> ComposeResult:
425425
app = pilot.app
426426
label = app.query_one(Label)
427427
assert label.loading == False
428-
assert len(label.query(LoadingIndicator)) == 0
428+
assert label._cover_widget is None
429429

430430
label.loading = True
431431
await pilot.pause()
432-
assert len(label.query(LoadingIndicator)) == 1
432+
assert label._cover_widget is not None
433433

434434
label.loading = True # Setting to same value is a null-op
435435
await pilot.pause()
436-
assert len(label.query(LoadingIndicator)) == 1
436+
assert label._cover_widget is not None
437437

438438
label.loading = False
439439
await pilot.pause()
440-
assert len(label.query(LoadingIndicator)) == 0
440+
assert label._cover_widget is None
441441

442442
label.loading = False # Setting to same value is a null-op
443443
await pilot.pause()
444-
assert len(label.query(LoadingIndicator)) == 0
444+
assert label._cover_widget is None
445445

446446

447447
async def test_is_mounted_property():

0 commit comments

Comments
 (0)