mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -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
|
||||
|
||||
1
docs/api/loading_indicator.md
Normal file
1
docs/api/loading_indicator.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.LoadingIndicator
|
||||
12
docs/examples/widgets/loading_indicator.py
Normal file
12
docs/examples/widgets/loading_indicator.py
Normal 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()
|
||||
@@ -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).
|
||||
|
||||
24
docs/widgets/loading_indicator.md
Normal file
24
docs/widgets/loading_indicator.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
63
src/textual/widgets/_loading_indicator.py
Normal file
63
src/textual/widgets/_loading_indicator.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user