Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new loading indicator method #5079

Merged
merged 7 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed

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

### Added

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

### Changed

- Breaking change: `Widget.set_loading` no longer return an awaitable https://github.com/Textualize/textual/pull/5079

## [0.81.0] - 2024-09-25

### Added
Expand Down
36 changes: 23 additions & 13 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,17 @@ def add_widget(

get_layer_index = layers_to_index.get

if widget._cover_widget is not None:
map[widget._cover_widget] = _MapGeometry(
region.shrink(widget.styles.gutter),
order,
clip,
region.size,
container_size,
virtual_region,
dock_gutter,
)

# Add all the widgets
for sub_region, _, sub_widget, z, fixed, overlay in reversed(
placements
Expand All @@ -681,18 +692,17 @@ def add_widget(
widget_region = self._constrain(
sub_widget.styles, widget_region, no_clip
)

add_widget(
sub_widget,
sub_region,
widget_region,
((1, 0, 0),) if overlay else widget_order,
layer_order,
no_clip if overlay else sub_clip,
visible,
arrange_result.scroll_spacing,
)

if widget._cover_widget is None:
add_widget(
sub_widget,
sub_region,
widget_region,
((1, 0, 0),) if overlay else widget_order,
layer_order,
no_clip if overlay else sub_clip,
visible,
arrange_result.scroll_spacing,
)
layer_order -= 1

if visible:
Expand Down Expand Up @@ -737,7 +747,7 @@ def add_widget(
if styles.constrain != "none":
widget_region = self._constrain(styles, widget_region, no_clip)

map[widget] = _MapGeometry(
map[widget._render_widget] = _MapGeometry(
widget_region,
order,
clip,
Expand Down
109 changes: 67 additions & 42 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from typing import (
TYPE_CHECKING,
AsyncGenerator,
Awaitable,
ClassVar,
Collection,
Generator,
Expand Down Expand Up @@ -58,7 +57,6 @@
from textual._styles_cache import StylesCache
from textual._types import AnimationLevel
from textual.actions import SkipAction
from textual.await_complete import AwaitComplete
from textual.await_remove import AwaitRemove
from textual.box_model import BoxModel
from textual.cache import FIFOCache
Expand Down Expand Up @@ -333,6 +331,38 @@ class Widget(DOMNode):
loading: Reactive[bool] = Reactive(False)
"""If set to `True` this widget will temporarily be replaced with a loading indicator."""

virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""

has_focus: Reactive[bool] = Reactive(False, repaint=False)
"""Does this widget have focus? Read only."""

mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
"""Is the mouse over this widget? Read only."""

scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the X axis."""

scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the Y axis."""

scroll_target_x = Reactive(0.0, repaint=False)
"""Scroll target destination, X coord."""

scroll_target_y = Reactive(0.0, repaint=False)
"""Scroll target destination, Y coord."""

show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a vertical scrollbar?"""

show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a horizontal scrollbar?"""

border_title: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the top border (if there is one)."""
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the bottom border (if there is one)."""

# Default sort order, incremented by constructor
_sort_order: ClassVar[int] = 0

Expand Down Expand Up @@ -430,38 +460,8 @@ def __init__(
"""An anchored child widget, or `None` if no child is anchored."""
self._anchor_animate: bool = False
"""Flag to enable animation when scrolling anchored widgets."""

virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""

has_focus: Reactive[bool] = Reactive(False, repaint=False)
"""Does this widget have focus? Read only."""

mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
"""Is the mouse over this widget? Read only."""

scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the X axis."""

scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the Y axis."""

scroll_target_x = Reactive(0.0, repaint=False)
"""Scroll target destination, X coord."""

scroll_target_y = Reactive(0.0, repaint=False)
"""Scroll target destination, Y coord."""

show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a vertical scrollbar?"""

show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a horizontal scrollbar?"""

border_title: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the top border (if there is one)."""
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the bottom border (if there is one)."""
self._cover_widget: Widget | None = None
"""Widget to render over this widget (used by loading indicator)."""

@property
def is_mounted(self) -> bool:
Expand Down Expand Up @@ -587,6 +587,33 @@ def is_maximized(self) -> bool:
except NoScreen:
return False

@property
def _render_widget(self) -> Widget:
"""The widget the compositor should render."""
# Will return the "cover widget" if one is set, otherwise self.
return self._cover_widget if self._cover_widget is not None else self

def _cover(self, widget: Widget) -> None:
"""Set a widget used to replace the visuals of this widget (used for loading indicator).

Args:
widget: A newly constructed, but unmounted widget.
"""
self._uncover()
self._cover_widget = widget
widget._parent = self
widget._start_messages()
widget._post_register(self.app)
self.app.stylesheet.apply(widget)
self.refresh(layout=True)

def _uncover(self) -> None:
"""Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover]."""
if self._cover_widget is not None:
self._cover_widget.remove()
self._cover_widget = None
self.refresh(layout=True)

def anchor(self, *, animate: bool = False) -> None:
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
but also keeps it in view if the widget's size changes, or the size of its container changes.
Expand Down Expand Up @@ -716,7 +743,7 @@ def get_loading_widget(self) -> Widget:
loading_widget = self.app.get_loading_widget()
return loading_widget

def set_loading(self, loading: bool) -> Awaitable:
def set_loading(self, loading: bool) -> None:
"""Set or reset the loading state of this widget.

A widget in a loading state will display a LoadingIndicator that obscures the widget.
Expand All @@ -728,19 +755,16 @@ def set_loading(self, loading: bool) -> Awaitable:
An optional awaitable.
"""
LOADING_INDICATOR_CLASS = "-textual-loading-indicator"
LOADING_INDICATOR_QUERY = f".{LOADING_INDICATOR_CLASS}"
remove_indicator = self.query_children(LOADING_INDICATOR_QUERY).remove()
if loading:
loading_indicator = self.get_loading_widget()
loading_indicator.add_class(LOADING_INDICATOR_CLASS)
await_mount = self.mount(loading_indicator)
return AwaitComplete(remove_indicator, await_mount).call_next(self)
self._cover(loading_indicator)
else:
return remove_indicator
self._uncover()

async def _watch_loading(self, loading: bool) -> None:
def _watch_loading(self, loading: bool) -> None:
"""Called when the 'loading' reactive is changed."""
await self.set_loading(loading)
self.set_loading(loading)

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

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

def _on_unmount(self) -> None:
self._uncover()
self.workers.cancel_node(self)

def action_scroll_home(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/_loading_indicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class LoadingIndicator(Widget):
min-height: 1;
content-align: center middle;
color: $accent;
text-style: not reverse;
}
LoadingIndicator.-textual-loading-indicator {
layer: _loading;
Expand Down
7 changes: 3 additions & 4 deletions tests/animations/test_loading_indicator_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"""

from textual.app import App
from textual.widgets import LoadingIndicator


async def test_loading_indicator_is_not_static_on_full() -> None:
Expand All @@ -15,7 +14,7 @@ async def test_loading_indicator_is_not_static_on_full() -> None:
async with app.run_test() as pilot:
app.screen.loading = True
await pilot.pause()
indicator = app.query_one(LoadingIndicator)
indicator = app.screen._cover_widget
assert str(indicator.render()) != "Loading..."


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


Expand All @@ -39,5 +38,5 @@ async def test_loading_indicator_is_static_on_none() -> None:
async with app.run_test() as pilot:
app.screen.loading = True
await pilot.pause()
indicator = app.query_one(LoadingIndicator)
indicator = app.screen._cover_widget
assert str(indicator.render()) == "Loading..."
10 changes: 5 additions & 5 deletions tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,23 +425,23 @@ def compose(self) -> ComposeResult:
app = pilot.app
label = app.query_one(Label)
assert label.loading == False
assert len(label.query(LoadingIndicator)) == 0
assert label._cover_widget is None

label.loading = True
await pilot.pause()
assert len(label.query(LoadingIndicator)) == 1
assert label._cover_widget is not None

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

label.loading = False
await pilot.pause()
assert len(label.query(LoadingIndicator)) == 0
assert label._cover_widget is None

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


async def test_is_mounted_property():
Expand Down
Loading