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

Loading indicator #2018

Merged
merged 7 commits into from
Mar 11, 2023
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/api/loading_indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual.widgets.LoadingIndicator
12 changes: 12 additions & 0 deletions docs/examples/widgets/loading_indicator.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions docs/widget_gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions docs/widgets/loading_indicator.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions src/textual/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,52 @@ def get_contrast_text(self, alpha=0.95) -> Color:
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)
Expand Down
1 change: 1 addition & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def children(self) -> Sequence["Widget"]:

@property
def auto_refresh(self) -> float | None:
"""Interval to refresh widget, or `None` for no automatic refresh."""
return self._auto_refresh

@auto_refresh.setter
Expand Down
3 changes: 2 additions & 1 deletion src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ async def _dispatch_message(self, message: Message) -> None:
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
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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
Expand All @@ -45,6 +46,7 @@
"Label",
"ListItem",
"ListView",
"LoadingIndicator",
"Markdown",
"MarkdownViewer",
"Placeholder",
Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/textual/widgets/_loading_indicator.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be public? If so it would be a good idea to add a docstring.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a property in Widget. But it was missing a docstring.


def render(self) -> RenderableType:
elapsed = time() - self._start_time
speed = 0.8
dot = "\u25CF"
davep marked this conversation as resolved.
Show resolved Hide resolved
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
30 changes: 29 additions & 1 deletion tests/test_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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():
Expand Down Expand Up @@ -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)