Loading indicator (#2018)

* loading indicator and tests

* docs

* snapshot

* remove snapshot

* remove debug main [skip ci]

* changelog [skip ci]

* make start time private
This commit is contained in:
Will McGugan
2023-03-11 08:36:13 +00:00
committed by GitHub
parent d3bdaf8ae5
commit 198190117d
13 changed files with 196 additions and 2 deletions

View File

@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
### Added
- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
## [0.14.0] - 2023-03-09
### Changed

View File

@@ -0,0 +1 @@
::: textual.widgets.LoadingIndicator

View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import LoadingIndicator
class LoadingApp(App):
def compose(self) -> ComposeResult:
yield LoadingIndicator()
if __name__ == "__main__":
app = LoadingApp()
app.run()

View File

@@ -109,6 +109,15 @@ Display a list of items (items may be other widgets).
```{.textual path="docs/examples/widgets/list_view.py"}
```
## LoadingIndicator
Display an animation while data is loading.
[LoadingIndicator reference](./widgets/loading_indicator.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/loading_indicator.py"}
```
## MarkdownViewer
Display and interact with a Markdown document (adds a table of contents and browser-like navigation to Markdown).

View File

@@ -0,0 +1,24 @@
# LoadingIndicator
Displays pulsating dots to indicate when data is being loaded.
- [ ] Focusable
- [ ] Container
=== "Output"
```{.textual path="docs/examples/widgets/loading_indicator.py"}
```
=== "loading_indicator.py"
```python
--8<-- "docs/examples/widgets/loading_indicator.py"
```
## See Also
* [LoadingIndicator](../api/loading_indicator.md) code reference

View File

@@ -132,6 +132,7 @@ nav:
- "widgets/label.md"
- "widgets/list_item.md"
- "widgets/list_view.md"
- "widgets/loading_indicator.md"
- "widgets/markdown_viewer.md"
- "widgets/markdown.md"
- "widgets/placeholder.md"
@@ -163,6 +164,7 @@ nav:
- "api/label.md"
- "api/list_item.md"
- "api/list_view.md"
- "api/loading_indicator.md"
- "api/markdown_viewer.md"
- "api/markdown.md"
- "api/message_pump.md"

View File

@@ -551,6 +551,52 @@ class Color(NamedTuple):
return (WHITE if white_contrast > black_contrast else BLACK).with_alpha(alpha)
class Gradient:
"""Defines a color gradient."""
def __init__(self, *stops: tuple[float, Color]) -> None:
"""Create a color gradient that blends colors to form a spectrum.
A gradient is defined by a sequence of "stops" consisting of a float and a color.
The stop indicate the color at that point on a spectrum between 0 and 1.
Args:
stops: A colors stop.
Raises:
ValueError: If any stops are missing (must be at least a stop for 0 and 1).
"""
self.stops = sorted(stops)
if len(stops) < 2:
raise ValueError("At least 2 stops required.")
if self.stops[0][0] != 0.0:
raise ValueError("First stop must be 0.")
if self.stops[-1][0] != 1.0:
raise ValueError("Last stop must be 1.")
def get_color(self, position: float) -> Color:
"""Get a color from the gradient at a position between 0 and 1.
Positions that are between stops will return a blended color.
Args:
factor: A number between 0 and 1, where 0 is the first stop, and 1 is the last.
Returns:
A color.
"""
# TODO: consider caching
position = clamp(position, 0.0, 1.0)
for (stop1, color1), (stop2, color2) in zip(self.stops, self.stops[1:]):
if stop2 >= position >= stop1:
return color1.blend(
color2,
(position - stop1) / (stop2 - stop1),
)
return self.stops[-1][1]
# Color constants
WHITE = Color(255, 255, 255)
BLACK = Color(0, 0, 0)

View File

@@ -158,6 +158,7 @@ class DOMNode(MessagePump):
@property
def auto_refresh(self) -> float | None:
"""Interval to refresh widget, or `None` for no automatic refresh."""
return self._auto_refresh
@auto_refresh.setter

View File

@@ -526,6 +526,7 @@ class MessagePump(metaclass=MessagePumpMeta):
await self.on_event(message)
else:
await self._on_message(message)
if self._next_callbacks:
await self._flush_next_callbacks()
def _get_dispatch_methods(

View File

@@ -21,6 +21,7 @@ if typing.TYPE_CHECKING:
from ._label import Label
from ._list_item import ListItem
from ._list_view import ListView
from ._loading_indicator import LoadingIndicator
from ._markdown import Markdown, MarkdownViewer
from ._placeholder import Placeholder
from ._pretty import Pretty
@@ -45,6 +46,7 @@ __all__ = [
"Label",
"ListItem",
"ListView",
"LoadingIndicator",
"Markdown",
"MarkdownViewer",
"Placeholder",

View File

@@ -10,6 +10,7 @@ from ._input import Input as Input
from ._label import Label as Label
from ._list_item import ListItem as ListItem
from ._list_view import ListView as ListView
from ._loading_indicator import LoadingIndicator as LoadingIndicator
from ._markdown import Markdown as Markdown
from ._markdown import MarkdownViewer as MarkdownViewer
from ._placeholder import Placeholder as Placeholder

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from time import time
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from ..color import Gradient
from ..widget import Widget
class LoadingIndicator(Widget):
"""Display an animated loading indicator."""
COMPONENT_CLASSES = {"loading-indicator--dot"}
DEFAULT_CSS = """
LoadingIndicator {
width: 100%;
height: 100%;
content-align: center middle;
}
LoadingIndicator > .loading-indicator--dot {
color: $accent;
}
"""
def on_mount(self) -> None:
self._start_time = time()
self.auto_refresh = 1 / 16
def render(self) -> RenderableType:
elapsed = time() - self._start_time
speed = 0.8
dot = "\u25CF"
dot_styles = self.get_component_styles("loading-indicator--dot")
base_style = self.rich_style
background = self.background_colors[-1]
color = dot_styles.color
gradient = Gradient(
(0.0, background.blend(color, 0.1)),
(0.7, color),
(1.0, color.lighten(0.1)),
)
blends = [(elapsed * speed - dot_number / 8) % 1 for dot_number in range(5)]
dots = [
(
f"{dot} ",
base_style
+ Style.from_color(gradient.get_color((1 - blend) ** 2).rich_color),
)
for blend in blends
]
indicator = Text.assemble(*dots)
indicator.rstrip()
return indicator

View File

@@ -2,7 +2,7 @@ import pytest
from rich.color import Color as RichColor
from rich.text import Text
from textual.color import Color, Lab, lab_to_rgb, rgb_to_lab
from textual.color import Color, Gradient, Lab, lab_to_rgb, rgb_to_lab
def test_rich_color():
@@ -215,3 +215,31 @@ def test_rgb_lab_rgb_roundtrip():
def test_inverse():
assert Color(55, 0, 255, 0.1).inverse == Color(200, 255, 0, 0.1)
def test_gradient_errors():
with pytest.raises(ValueError):
Gradient()
with pytest.raises(ValueError):
Gradient((0, Color.parse("red")))
with pytest.raises(ValueError):
Gradient(
(0, Color.parse("red")),
(0.8, Color.parse("blue")),
)
def test_gradient():
gradient = Gradient(
(0, Color(255, 0, 0)),
(0.5, Color(0, 0, 255)),
(1, Color(0, 255, 0)),
)
assert gradient.get_color(-1) == Color(255, 0, 0)
assert gradient.get_color(0) == Color(255, 0, 0)
assert gradient.get_color(1) == Color(0, 255, 0)
assert gradient.get_color(1.2) == Color(0, 255, 0)
assert gradient.get_color(0.5) == Color(0, 0, 255)
assert gradient.get_color(0.7) == Color(0, 101, 153)