diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6ece47c7..d65d0199c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
-## [0.15.0] - Unreleased
+## Unreleased
### Changed
@@ -14,11 +14,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
+### Fixed
+
+- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
+- Fixes issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
## [0.14.0] - 2023-03-09
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/styles/scrollbar_size.py b/docs/examples/styles/scrollbar_size.py
index e949aaaca..bf4390c31 100644
--- a/docs/examples/styles/scrollbar_size.py
+++ b/docs/examples/styles/scrollbar_size.py
@@ -1,5 +1,5 @@
from textual.app import App
-from textual.containers import VerticalScroll
+from textual.containers import Container
from textual.widgets import Label
TEXT = """I must not fear.
@@ -14,7 +14,7 @@ Where the fear has gone there will be nothing. Only I will remain.
class ScrollbarApp(App):
def compose(self):
- yield VerticalScroll(Label(TEXT * 5), classes="panel")
+ yield Container(Label(TEXT * 5), classes="panel")
app = ScrollbarApp(css_path="scrollbar_size.css")
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/getting_started.md b/docs/getting_started.md
index 48ac26378..82739d337 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -44,7 +44,7 @@ python -m textual
If Textual is installed you should see the following:
-```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,_,_,_,_,_,_,tab,_,w,i,l,l"}
+```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,tab,w,i,l,l"}
```
## Examples
diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md
index 985c4b41f..49d297b8f 100644
--- a/docs/guide/CSS.md
+++ b/docs/guide/CSS.md
@@ -68,7 +68,7 @@ Let's look at a trivial Textual app.
=== "Output"
- ```{.textual path="docs/examples/guide/dom1.py" press="_"}
+ ```{.textual path="docs/examples/guide/dom1.py"}
```
This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`.
diff --git a/docs/guide/app.md b/docs/guide/app.md
index 891ea72a5..382e55aba 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -27,7 +27,7 @@ Apps don't get much simpler than this—don't expect it to do much.
If we run this app with `python simple02.py` you will see a blank terminal, something like the following:
-```{.textual path="docs/examples/app/simple02.py" press="_"}
+```{.textual path="docs/examples/app/simple02.py"}
```
When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*).
@@ -57,7 +57,7 @@ Another such event is the *key* event which is sent when the user presses a key.
The `on_mount` handler sets the `self.screen.styles.background` attribute to `"darkblue"` which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
-```{.textual path="docs/examples/app/event01.py" hl_lines="23-25" press="_"}
+```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"}
```
The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
@@ -114,7 +114,7 @@ Here's an app which adds a welcome widget in response to any key press:
When you first run this you will get a blank screen. Press any key to add the welcome widget. You can even press a key multiple times to add several widgets.
-```{.textual path="docs/examples/app/widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"}
+```{.textual path="docs/examples/app/widgets02.py" press="a,a,a,down,down,down,down,down,down"}
```
## Exiting
diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md
index 8643073ff..fccbce004 100644
--- a/docs/guide/devtools.md
+++ b/docs/guide/devtools.md
@@ -60,7 +60,7 @@ textual console
You should see the Textual devtools welcome message:
-```{.textual title="textual console" path="docs/examples/getting_started/console.py", press="_,_"}
+```{.textual title="textual console" path="docs/examples/getting_started/console.py"}
```
In the other console, run your application with `textual run` and the `--dev` switch:
diff --git a/docs/guide/input.md b/docs/guide/input.md
index c92421266..adc7ebea7 100644
--- a/docs/guide/input.md
+++ b/docs/guide/input.md
@@ -20,7 +20,7 @@ The most fundamental way to receive input is via [Key][textual.events.Key] event
=== "Output"
- ```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"}
+ ```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!"}
```
When you press a key, the app will receive the event and write it to a [TextLog](../widgets/text_log.md) widget. Try pressing a few keys to see what happens.
@@ -102,7 +102,7 @@ The following example shows how focus works in practice.
=== "Output"
- ```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!,_"}
+ ```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!"}
```
The app splits the screen in to quarters, with a `TextLog` widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that the widget has focus. Key events will be sent to the focused widget only.
diff --git a/docs/guide/layout.md b/docs/guide/layout.md
index a39da2f8e..3564208a9 100644
--- a/docs/guide/layout.md
+++ b/docs/guide/layout.md
@@ -444,7 +444,7 @@ The code below shows a simple sidebar implementation.
=== "Output"
- ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
+ ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout1_sidebar.py"
@@ -468,7 +468,7 @@ This new sidebar is double the width of the one previous one, and has a `deeppin
=== "Output"
- ```{.textual path="docs/examples/guide/layout/dock_layout2_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
+ ```{.textual path="docs/examples/guide/layout/dock_layout2_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout2_sidebar.py"
diff --git a/docs/guide/screens.md b/docs/guide/screens.md
index 28a048e25..5ddefe31f 100644
--- a/docs/guide/screens.md
+++ b/docs/guide/screens.md
@@ -36,7 +36,7 @@ Let's look at a simple example of writing a screen class to simulate Window's [b
=== "Output"
- ```{.textual path="docs/examples/guide/screens/screen01.py" press="b,_"}
+ ```{.textual path="docs/examples/guide/screens/screen01.py" press="b"}
```
If you run this you will see an empty screen. Hit the ++b++ key to show a blue screen of death. Hit ++escape++ to return to the default screen.
@@ -65,7 +65,7 @@ You can also _install_ new named screens dynamically with the [install_screen][t
=== "Output"
- ```{.textual path="docs/examples/guide/screens/screen02.py" press="b,_"}
+ ```{.textual path="docs/examples/guide/screens/screen02.py" press="b"}
```
Although both do the same thing, we recommend `SCREENS` for screens that exist for the lifetime of your app.
@@ -145,7 +145,7 @@ Screens can be used to implement modal dialogs. The following example pushes a s
=== "Output"
- ```{.textual path="docs/examples/guide/screens/modal01.py" press="q,_"}
+ ```{.textual path="docs/examples/guide/screens/modal01.py" press="q"}
```
Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`. This makes the quit screen active. if you click cancel, the quit screen calls `pop_screen` to return the default screen. This also removes and deletes the `QuitScreen` object.
diff --git a/docs/guide/styles.md b/docs/guide/styles.md
index bcaa05db6..436228638 100644
--- a/docs/guide/styles.md
+++ b/docs/guide/styles.md
@@ -20,7 +20,7 @@ The first line sets the [background](../styles/background.md) style to `"darkblu
The second line sets [border](../styles/border.md) to a tuple of `("heavy", "white")` which tells Textual to draw a white border with a style of `"heavy"`. Running this code will show the following:
-```{.textual path="docs/examples/guide/styles/screen.py" press="_"}
+```{.textual path="docs/examples/guide/styles/screen.py"}
```
## Styling widgets
diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md
index 78e222894..8dad39114 100644
--- a/docs/guide/widgets.md
+++ b/docs/guide/widgets.md
@@ -136,7 +136,7 @@ Let's use markup links in the hello example so that the greeting becomes a link
=== "Output"
- ```{.textual path="docs/examples/guide/widgets/hello05.py" press="_"}
+ ```{.textual path="docs/examples/guide/widgets/hello05.py"}
```
If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the `next_word` action which updates the next word.
diff --git a/docs/index.md b/docs/index.md
index b3ca42e71..c8e48c574 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -77,7 +77,7 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t
```{.textual path="examples/pride.py"}
```
-```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter,_,_"}
+```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter"}
```
diff --git a/docs/styles/dock.md b/docs/styles/dock.md
index 9fbd241ac..28462fd4f 100644
--- a/docs/styles/dock.md
+++ b/docs/styles/dock.md
@@ -19,7 +19,7 @@ Notice that even though the content is scrolled, the sidebar remains fixed.
=== "Output"
- ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
+ ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout1_sidebar.py"
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 7d37eea58..532557acc 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -25,7 +25,7 @@ This will be a simple yet **fully featured** app — you could distribute th
Here's what the finished app will look like:
-```{.textual path="docs/examples/tutorial/stopwatch.py" press="tab,enter,_,tab,enter,_,tab,_,enter,_,tab,enter,_,_"}
+```{.textual path="docs/examples/tutorial/stopwatch.py" press="tab,enter,tab,enter,tab,enter,tab,enter"}
```
### Get the code
@@ -339,7 +339,7 @@ The `on_button_pressed` method is an *event handler*. Event handlers are methods
If you run `stopwatch04.py` now you will be able to toggle between the two states by clicking the first button:
-```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,_,enter,_,_,_"}
+```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"}
```
## Reactive attributes
@@ -421,7 +421,7 @@ This code supplies missing features and makes our app useful. We've made the fol
If you run `stopwatch06.py` you will be able to use the stopwatches independently.
-```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"}
+```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,tab,enter,tab"}
```
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.
@@ -449,7 +449,7 @@ The `action_remove_stopwatch` function calls [query()][textual.dom.DOMNode.query
If you run `stopwatch.py` now you can add a new stopwatch with the ++a++ key and remove a stopwatch with ++r++.
-```{.textual path="docs/examples/tutorial/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,_,_,_,_,tab,_"}
+```{.textual path="docs/examples/tutorial/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,tab"}
```
## What next?
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index 149804326..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).
@@ -182,7 +191,7 @@ Display and update text in a scrolling panel.
[TextLog reference](./widgets/text_log.md){ .md-button .md-button--primary }
-```{.textual path="docs/examples/widgets/text_log.py" press="_,H,i"}
+```{.textual path="docs/examples/widgets/text_log.py" press="H,i"}
```
## Tree
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/docs/widgets/text_log.md b/docs/widgets/text_log.md
index bb491a28a..d354fc24b 100644
--- a/docs/widgets/text_log.md
+++ b/docs/widgets/text_log.md
@@ -13,7 +13,7 @@ The example below shows an application showing a `TextLog` with different kinds
=== "Output"
- ```{.textual path="docs/examples/widgets/text_log.py" press="_,H,i"}
+ ```{.textual path="docs/examples/widgets/text_log.py" press="H,i"}
```
=== "text_log.py"
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/pyproject.toml b/pyproject.toml
index a3291312e..b16764bf1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,7 +48,7 @@ typing-extensions = "^4.4.0"
aiohttp = { version = ">=3.8.1", optional = true }
click = {version = ">=8.1.2", optional = true}
msgpack = { version = ">=1.0.3", optional = true }
-mkdocs-exclude = "^1.0.2"
+mkdocs-exclude = { version = "^1.0.2", optional = true }
[tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"]
diff --git a/src/textual/app.py b/src/textual/app.py
index add4df575..9c1d1606e 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -2175,7 +2175,7 @@ class App(Generic[ReturnType], DOMNode):
for child in widget._nodes:
push(child)
- def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:
+ def _remove_nodes(self, widgets: list[Widget], parent: DOMNode) -> AwaitRemove:
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
Args:
@@ -2198,7 +2198,8 @@ class App(Generic[ReturnType], DOMNode):
await self._prune_nodes(widgets)
finally:
finished_event.set()
- self.refresh(layout=True)
+ if parent.styles.auto_dimensions:
+ parent.refresh(layout=True)
removed_widgets = self._detach_from_dom(widgets)
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/css/query.py b/src/textual/css/query.py
index 428209fa4..73e09c61b 100644
--- a/src/textual/css/query.py
+++ b/src/textual/css/query.py
@@ -355,7 +355,7 @@ class DOMQuery(Generic[QueryType]):
An awaitable object that waits for the widgets to be removed.
"""
app = active_app.get()
- await_remove = app._remove_nodes(list(self))
+ await_remove = app._remove_nodes(list(self), self._node)
return await_remove
def set_styles(
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/driver.py b/src/textual/driver.py
index 0e8ac6412..3aee951ef 100644
--- a/src/textual/driver.py
+++ b/src/textual/driver.py
@@ -55,6 +55,7 @@ class Driver(ABC):
and not event.button
and self._last_move_event is not None
):
+ # Deduplicate self._down_buttons while preserving order.
buttons = list(dict.fromkeys(self._down_buttons).keys())
self._down_buttons.clear()
move_event = self._last_move_event
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/widget.py b/src/textual/widget.py
index a2560830f..5cc76f767 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -1010,11 +1010,11 @@ class Widget(DOMNode):
show_vertical = self.virtual_size.height > height
# When a single scrollbar is shown, the other dimension changes, so we need to recalculate.
- if show_vertical and not show_horizontal:
+ if overflow_x == "auto" and show_vertical and not show_horizontal:
show_horizontal = self.virtual_size.width > (
width - styles.scrollbar_size_vertical
)
- if show_horizontal and not show_vertical:
+ if overflow_y == "auto" and show_horizontal and not show_vertical:
show_vertical = self.virtual_size.height > (
height - styles.scrollbar_size_horizontal
)
@@ -2548,7 +2548,7 @@ class Widget(DOMNode):
An awaitable object that waits for the widget to be removed.
"""
- await_remove = self.app._remove_nodes([self])
+ await_remove = self.app._remove_nodes([self], self.parent)
return await_remove
def render(self) -> RenderableType:
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/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 19dfd1a6c..1518261b9 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -160,6 +160,231 @@
'''
# ---
+# name: test_auto_table
+ '''
+
+
+ '''
+# ---
# name: test_auto_width_input
'''