From 198190117dd10123f89994ad72330b0eb3d5dc8e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 11 Mar 2023 08:36:13 +0000 Subject: [PATCH] Loading indicator (#2018) * loading indicator and tests * docs * snapshot * remove snapshot * remove debug main [skip ci] * changelog [skip ci] * make start time private --- CHANGELOG.md | 4 ++ docs/api/loading_indicator.md | 1 + docs/examples/widgets/loading_indicator.py | 12 +++++ docs/widget_gallery.md | 9 ++++ docs/widgets/loading_indicator.md | 24 +++++++++ mkdocs-nav.yml | 2 + src/textual/color.py | 46 ++++++++++++++++ src/textual/dom.py | 1 + src/textual/message_pump.py | 3 +- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_loading_indicator.py | 63 ++++++++++++++++++++++ tests/test_color.py | 30 ++++++++++- 13 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 docs/api/loading_indicator.md create mode 100644 docs/examples/widgets/loading_indicator.py create mode 100644 docs/widgets/loading_indicator.md create mode 100644 src/textual/widgets/_loading_indicator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e74ce64f..91045d60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/loading_indicator.md b/docs/api/loading_indicator.md new file mode 100644 index 000000000..14b176233 --- /dev/null +++ b/docs/api/loading_indicator.md @@ -0,0 +1 @@ +::: textual.widgets.LoadingIndicator diff --git a/docs/examples/widgets/loading_indicator.py b/docs/examples/widgets/loading_indicator.py new file mode 100644 index 000000000..a7df94bf2 --- /dev/null +++ b/docs/examples/widgets/loading_indicator.py @@ -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() diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index cb8d7cdc4..908fe5a65 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -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). diff --git a/docs/widgets/loading_indicator.md b/docs/widgets/loading_indicator.md new file mode 100644 index 000000000..cd3e9ffc4 --- /dev/null +++ b/docs/widgets/loading_indicator.md @@ -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 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 820d601a7..b24b28201 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -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" diff --git a/src/textual/color.py b/src/textual/color.py index a5bde511a..99e3796df 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -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) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0a32f7f16..457131030 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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 diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index fd80a6874..3a1ac9eb2 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -526,7 +526,8 @@ class MessagePump(metaclass=MessagePumpMeta): await self.on_event(message) else: await self._on_message(message) - await self._flush_next_callbacks() + if self._next_callbacks: + await self._flush_next_callbacks() def _get_dispatch_methods( self, method_name: str, message: Message diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 5dc515dea..154d9d26d 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -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", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 5fe292f2d..2beb8a808 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -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 diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py new file mode 100644 index 000000000..15cdf8ffc --- /dev/null +++ b/src/textual/widgets/_loading_indicator.py @@ -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 diff --git a/tests/test_color.py b/tests/test_color.py index c4ef8311c..03c34bd19 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -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)