Skip to content

Allow custom spinners across rich library #3791

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

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Added `TTY_INTERACTIVE` environment variable to force interactive mode off or on https://github.com/Textualize/rich/pull/3777
- Allowed custom spinner animations throughout the library https://github.com/Textualize/rich/pull/3791

## [14.0.0] - 2025-03-30

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@ The following people have contributed to the development of Rich:
- [Jonathan Helmus](https://github.com/jjhelmus)
- [Brandon Capener](https://github.com/bcapener)
- [Alex Zheng](https://github.com/alexzheng111)
- [Maddy Guthridge](https://maddyguthridge.com/)
4 changes: 4 additions & 0 deletions docs/source/console.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ Run the following command to see the available choices for ``spinner``::

python -m rich.spinner

You can use a custom spinner by providing a dictionary with the following properties

* ``"interval"`` Intended time per frame of spinner
* ``"frames"`` Frames of the spinner. If this is a single ``str``, each character is a single frame. If a ``list[str]`` is given, each list element is a single frame.

Justify / Alignment
-------------------
Expand Down
15 changes: 14 additions & 1 deletion rich/_spinners.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@
IN THE SOFTWARE.
"""

SPINNERS = {
from typing import TypedDict, List, Dict, Union


class SpinnerAnimation(TypedDict):
interval: float
"""Intended time per frame, in milliseconds"""
frames: Union[List[str], str]
"""
Frames of this spinner. If a single `str`, each character is a single
frame. If a `list[str]`, each list element is a single frame.
"""


SPINNERS: Dict[str, SpinnerAnimation] = {
"dots": {
"interval": 80,
"frames": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
Expand Down
15 changes: 8 additions & 7 deletions rich/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)

from rich._null_file import NULL_FILE
from rich._spinners import SpinnerAnimation

from . import errors, themes
from ._emoji_replace import _emoji_replace
Expand Down Expand Up @@ -1163,7 +1164,7 @@ def status(
self,
status: RenderableType,
*,
spinner: str = "dots",
spinner: Union[str, SpinnerAnimation] = "dots",
spinner_style: StyleType = "status.spinner",
speed: float = 1.0,
refresh_per_second: float = 12.5,
Expand Down Expand Up @@ -2181,9 +2182,9 @@ def export_text(self, *, clear: bool = True, styles: bool = False) -> str:
str: String containing console contents.

"""
assert (
self.record
), "To export console contents set record=True in the constructor or instance"
assert self.record, (
"To export console contents set record=True in the constructor or instance"
)

with self._record_buffer_lock:
if styles:
Expand Down Expand Up @@ -2237,9 +2238,9 @@ def export_html(
Returns:
str: String containing console contents as HTML.
"""
assert (
self.record
), "To export console contents set record=True in the constructor or instance"
assert self.record, (
"To export console contents set record=True in the constructor or instance"
)
fragments: List[str] = []
append = fragments.append
_theme = theme or DEFAULT_TERMINAL_THEME
Expand Down
6 changes: 3 additions & 3 deletions rich/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from .jupyter import JupyterMixin
from .live import Live
from .progress_bar import ProgressBar
from .spinner import Spinner
from .spinner import Spinner, SpinnerAnimation
from .style import StyleType
from .table import Column, Table
from .text import Text, TextType
Expand Down Expand Up @@ -575,7 +575,7 @@ class SpinnerColumn(ProgressColumn):

def __init__(
self,
spinner_name: str = "dots",
spinner_name: Union[str, SpinnerAnimation] = "dots",
style: Optional[StyleType] = "progress.spinner",
speed: float = 1.0,
finished_text: TextType = " ",
Expand All @@ -591,7 +591,7 @@ def __init__(

def set_spinner(
self,
spinner_name: str,
spinner_name: Union[str, SpinnerAnimation],
spinner_style: Optional[StyleType] = "progress.spinner",
speed: float = 1.0,
) -> None:
Expand Down
32 changes: 22 additions & 10 deletions rich/spinner.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, List, Optional, Union, cast
from typing import TYPE_CHECKING, Optional, Union

from ._spinners import SPINNERS
from ._spinners import SPINNERS, SpinnerAnimation
from .measure import Measurement
from .table import Table
from .text import Text
Expand All @@ -10,11 +10,19 @@
from .style import StyleType


# Explicitly export `SpinnerInfo` to avoid linter annoyances if other people
# want to use our type definition.
__all__ = [
"Spinner",
"SpinnerAnimation",
]


class Spinner:
"""A spinner animation.

Args:
name (str): Name of spinner (run python -m rich.spinner).
name (str | SpinnerInfo): Name of spinner (run python -m rich.spinner), or a dict of shape { "interval": float, "frames": str | list[str] }
text (RenderableType, optional): A renderable to display at the right of the spinner (str or Text typically). Defaults to "".
style (StyleType, optional): Style for spinner animation. Defaults to None.
speed (float, optional): Speed factor for animation. Defaults to 1.0.
Expand All @@ -25,22 +33,26 @@ class Spinner:

def __init__(
self,
name: str,
name: str | SpinnerAnimation,
text: "RenderableType" = "",
*,
style: Optional["StyleType"] = None,
speed: float = 1.0,
) -> None:
try:
spinner = SPINNERS[name]
except KeyError:
raise KeyError(f"no spinner called {name!r}")
if isinstance(name, str):
try:
spinner = SPINNERS[name]
except KeyError:
raise KeyError(f"no spinner called {name!r}")
else:
spinner = name

self.text: "Union[RenderableType, Text]" = (
Text.from_markup(text) if isinstance(text, str) else text
)
self.name = name
self.frames = cast(List[str], spinner["frames"])[:]
self.interval = cast(float, spinner["interval"])
self.frames = spinner["frames"][:]
self.interval = spinner["interval"]
self.start_time: Optional[float] = None
self.style = style
self.speed = speed
Expand Down
7 changes: 4 additions & 3 deletions rich/status.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from types import TracebackType
from typing import Optional, Type
from typing import Optional, Type, Union

from ._spinners import SpinnerAnimation
from .console import Console, RenderableType
from .jupyter import JupyterMixin
from .live import Live
Expand All @@ -25,7 +26,7 @@ def __init__(
status: RenderableType,
*,
console: Optional[Console] = None,
spinner: str = "dots",
spinner: Union[str, SpinnerAnimation] = "dots",
spinner_style: StyleType = "status.spinner",
speed: float = 1.0,
refresh_per_second: float = 12.5,
Expand Down Expand Up @@ -54,7 +55,7 @@ def update(
self,
status: Optional[RenderableType] = None,
*,
spinner: Optional[str] = None,
spinner: Union[str, SpinnerAnimation, None] = None,
spinner_style: Optional[StyleType] = None,
speed: Optional[float] = None,
) -> None:
Expand Down
27 changes: 26 additions & 1 deletion tests/test_spinner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from rich.console import Console
from rich.measure import Measurement
from rich.rule import Rule
from rich.spinner import Spinner
from rich.spinner import Spinner, SpinnerAnimation
from rich.text import Text


Expand Down Expand Up @@ -70,3 +70,28 @@ def test_spinner_markup():
spinner = Spinner("dots", "[bold]spinning[/bold]")
assert isinstance(spinner.text, Text)
assert str(spinner.text) == "spinning"


def test_custom_spinner_render():
custom_spinner: SpinnerAnimation = {
"interval": 80,
"frames": "abcdef",
}
time = 0.0

def get_time():
nonlocal time
return time

console = Console(
width=80, color_system=None, force_terminal=True, get_time=get_time
)
console.begin_capture()
spinner = Spinner(custom_spinner, "Foo")
console.print(spinner)
time += 80 / 1000
console.print(spinner)
result = console.end_capture()
print(repr(result))
expected = "a Foo\nb Foo\n"
assert result == expected