From 8a66a99a0b58f1d48148f5be392f67109738fd48 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 01:54:51 +1000 Subject: [PATCH 1/7] Support custom spinner definitions --- rich/_spinners.py | 15 ++++++++++++++- rich/spinner.py | 32 ++++++++++++++++++++++---------- tests/test_spinner.py | 27 ++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/rich/_spinners.py b/rich/_spinners.py index d0bb1fe75..f75965626 100644 --- a/rich/_spinners.py +++ b/rich/_spinners.py @@ -19,7 +19,20 @@ IN THE SOFTWARE. """ -SPINNERS = { +from typing import TypedDict, List, Dict + + +class SpinnerInfo(TypedDict): + interval: float + """Intended time per frame, in milliseconds""" + frames: 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, SpinnerInfo] = { "dots": { "interval": 80, "frames": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", diff --git a/rich/spinner.py b/rich/spinner.py index a3a3caf84..1288d5b89 100644 --- a/rich/spinner.py +++ b/rich/spinner.py @@ -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, SpinnerInfo from .measure import Measurement from .table import Table from .text import Text @@ -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", + "SpinnerInfo", +] + + 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. @@ -25,22 +33,26 @@ class Spinner: def __init__( self, - name: str, + name: str | SpinnerInfo, 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 diff --git a/tests/test_spinner.py b/tests/test_spinner.py index efeeb7173..d751fb727 100644 --- a/tests/test_spinner.py +++ b/tests/test_spinner.py @@ -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, SpinnerInfo from rich.text import Text @@ -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: SpinnerInfo = { + "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 From 1c9d05d406a6cdac5f839af0b8116f2388b816db Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:01:29 +1000 Subject: [PATCH 2/7] Also support custom spinners in Console and Status --- rich/console.py | 3 ++- rich/status.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rich/console.py b/rich/console.py index 7d4d4a8f6..fb7aa2964 100644 --- a/rich/console.py +++ b/rich/console.py @@ -36,6 +36,7 @@ ) from rich._null_file import NULL_FILE +from rich._spinners import SpinnerInfo from . import errors, themes from ._emoji_replace import _emoji_replace @@ -1163,7 +1164,7 @@ def status( self, status: RenderableType, *, - spinner: str = "dots", + spinner: Union[str, SpinnerInfo] = "dots", spinner_style: StyleType = "status.spinner", speed: float = 1.0, refresh_per_second: float = 12.5, diff --git a/rich/status.py b/rich/status.py index 65744838e..bf2a50216 100644 --- a/rich/status.py +++ b/rich/status.py @@ -1,6 +1,7 @@ from types import TracebackType -from typing import Optional, Type +from typing import Optional, Type, Union +from ._spinners import SpinnerInfo from .console import Console, RenderableType from .jupyter import JupyterMixin from .live import Live @@ -25,7 +26,7 @@ def __init__( status: RenderableType, *, console: Optional[Console] = None, - spinner: str = "dots", + spinner: Union[str, SpinnerInfo] = "dots", spinner_style: StyleType = "status.spinner", speed: float = 1.0, refresh_per_second: float = 12.5, @@ -54,7 +55,7 @@ def update( self, status: Optional[RenderableType] = None, *, - spinner: Optional[str] = None, + spinner: Union[str, SpinnerInfo, None] = None, spinner_style: Optional[StyleType] = None, speed: Optional[float] = None, ) -> None: From c708a8e4772aa91a0046c47ff7fa51f401a62399 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:10:12 +1000 Subject: [PATCH 3/7] Also allow custom spinners for rich.progress.SpinnerColumn --- rich/progress.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rich/progress.py b/rich/progress.py index ef6ad60f0..19c959bfb 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -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, SpinnerInfo from .style import StyleType from .table import Column, Table from .text import Text, TextType @@ -575,7 +575,7 @@ class SpinnerColumn(ProgressColumn): def __init__( self, - spinner_name: str = "dots", + spinner_name: Union[str, SpinnerInfo] = "dots", style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, finished_text: TextType = " ", @@ -591,7 +591,7 @@ def __init__( def set_spinner( self, - spinner_name: str, + spinner_name: Union[str, SpinnerInfo], spinner_style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, ) -> None: From e30e41f80244bc09ec32e49eaef3e36e529073e3 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:10:23 +1000 Subject: [PATCH 4/7] Document custom spinners in console.rst --- docs/source/console.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/console.rst b/docs/source/console.rst index 4d79886c2..5ee698773 100644 --- a/docs/source/console.rst +++ b/docs/source/console.rst @@ -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 ------------------- From 018fa865674d04e415ab669ef7ead55d22906dd4 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:10:34 +1000 Subject: [PATCH 5/7] Fix Python 3.8/9 incompatibility --- rich/_spinners.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rich/_spinners.py b/rich/_spinners.py index f75965626..9c00a7b2a 100644 --- a/rich/_spinners.py +++ b/rich/_spinners.py @@ -19,13 +19,13 @@ IN THE SOFTWARE. """ -from typing import TypedDict, List, Dict +from typing import TypedDict, List, Dict, Union class SpinnerInfo(TypedDict): interval: float """Intended time per frame, in milliseconds""" - frames: List[str] | str + 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. From c5cc41895dfb360a88a3202c7b68aca3468f4e61 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:12:36 +1000 Subject: [PATCH 6/7] Rename `SpinnerInfo` type to `SpinnerAnimation` --- rich/_spinners.py | 4 ++-- rich/console.py | 16 ++++++++-------- rich/progress.py | 6 +++--- rich/spinner.py | 6 +++--- rich/status.py | 6 +++--- tests/test_spinner.py | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/rich/_spinners.py b/rich/_spinners.py index 9c00a7b2a..4e5275cef 100644 --- a/rich/_spinners.py +++ b/rich/_spinners.py @@ -22,7 +22,7 @@ from typing import TypedDict, List, Dict, Union -class SpinnerInfo(TypedDict): +class SpinnerAnimation(TypedDict): interval: float """Intended time per frame, in milliseconds""" frames: Union[List[str], str] @@ -32,7 +32,7 @@ class SpinnerInfo(TypedDict): """ -SPINNERS: Dict[str, SpinnerInfo] = { +SPINNERS: Dict[str, SpinnerAnimation] = { "dots": { "interval": 80, "frames": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", diff --git a/rich/console.py b/rich/console.py index fb7aa2964..034a606e3 100644 --- a/rich/console.py +++ b/rich/console.py @@ -36,7 +36,7 @@ ) from rich._null_file import NULL_FILE -from rich._spinners import SpinnerInfo +from rich._spinners import SpinnerAnimation from . import errors, themes from ._emoji_replace import _emoji_replace @@ -1164,7 +1164,7 @@ def status( self, status: RenderableType, *, - spinner: Union[str, SpinnerInfo] = "dots", + spinner: Union[str, SpinnerAnimation] = "dots", spinner_style: StyleType = "status.spinner", speed: float = 1.0, refresh_per_second: float = 12.5, @@ -2182,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: @@ -2238,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 diff --git a/rich/progress.py b/rich/progress.py index 19c959bfb..cfd16008e 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -46,7 +46,7 @@ from .jupyter import JupyterMixin from .live import Live from .progress_bar import ProgressBar -from .spinner import Spinner, SpinnerInfo +from .spinner import Spinner, SpinnerAnimation from .style import StyleType from .table import Column, Table from .text import Text, TextType @@ -575,7 +575,7 @@ class SpinnerColumn(ProgressColumn): def __init__( self, - spinner_name: Union[str, SpinnerInfo] = "dots", + spinner_name: Union[str, SpinnerAnimation] = "dots", style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, finished_text: TextType = " ", @@ -591,7 +591,7 @@ def __init__( def set_spinner( self, - spinner_name: Union[str, SpinnerInfo], + spinner_name: Union[str, SpinnerAnimation], spinner_style: Optional[StyleType] = "progress.spinner", speed: float = 1.0, ) -> None: diff --git a/rich/spinner.py b/rich/spinner.py index 1288d5b89..4e962b218 100644 --- a/rich/spinner.py +++ b/rich/spinner.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Optional, Union -from ._spinners import SPINNERS, SpinnerInfo +from ._spinners import SPINNERS, SpinnerAnimation from .measure import Measurement from .table import Table from .text import Text @@ -14,7 +14,7 @@ # want to use our type definition. __all__ = [ "Spinner", - "SpinnerInfo", + "SpinnerAnimation", ] @@ -33,7 +33,7 @@ class Spinner: def __init__( self, - name: str | SpinnerInfo, + name: str | SpinnerAnimation, text: "RenderableType" = "", *, style: Optional["StyleType"] = None, diff --git a/rich/status.py b/rich/status.py index bf2a50216..6d1feb10b 100644 --- a/rich/status.py +++ b/rich/status.py @@ -1,7 +1,7 @@ from types import TracebackType from typing import Optional, Type, Union -from ._spinners import SpinnerInfo +from ._spinners import SpinnerAnimation from .console import Console, RenderableType from .jupyter import JupyterMixin from .live import Live @@ -26,7 +26,7 @@ def __init__( status: RenderableType, *, console: Optional[Console] = None, - spinner: Union[str, SpinnerInfo] = "dots", + spinner: Union[str, SpinnerAnimation] = "dots", spinner_style: StyleType = "status.spinner", speed: float = 1.0, refresh_per_second: float = 12.5, @@ -55,7 +55,7 @@ def update( self, status: Optional[RenderableType] = None, *, - spinner: Union[str, SpinnerInfo, None] = None, + spinner: Union[str, SpinnerAnimation, None] = None, spinner_style: Optional[StyleType] = None, speed: Optional[float] = None, ) -> None: diff --git a/tests/test_spinner.py b/tests/test_spinner.py index d751fb727..db1baba3c 100644 --- a/tests/test_spinner.py +++ b/tests/test_spinner.py @@ -3,7 +3,7 @@ from rich.console import Console from rich.measure import Measurement from rich.rule import Rule -from rich.spinner import Spinner, SpinnerInfo +from rich.spinner import Spinner, SpinnerAnimation from rich.text import Text @@ -73,7 +73,7 @@ def test_spinner_markup(): def test_custom_spinner_render(): - custom_spinner: SpinnerInfo = { + custom_spinner: SpinnerAnimation = { "interval": 80, "frames": "abcdef", } From 381ade046d6288ef3e8dc598fbf9296fdace83b1 Mon Sep 17 00:00:00 2001 From: Maddy Guthridge Date: Sun, 13 Jul 2025 02:26:33 +1000 Subject: [PATCH 7/7] Update changelog, add myself as contributor --- CHANGELOG.md | 1 + CONTRIBUTORS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f24a4ea7..61bfb6476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b04786b9..a0cae1476 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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/)