Files
MeshTerm/meshterm/ui/tui/spinner.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

92 lines
3.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""A reusable text spinner: one animated glyph for a "working" indicator.
A small, screen-agnostic animation helper. It holds a cycle of frame glyphs and a current
position; a caller advances it with :meth:`tick` (typically from an animation timer) and
reads the current frame as a bare glyph or as a styled Rich :class:`~rich.text.Text`. The
busy splash uses it, but it is deliberately independent of any screen so any "working"
indicator across the toolkit can reuse the same animation.
"""
from __future__ import annotations
from rich.text import Text
from ...platforms import get_platform
def spinner_interval() -> float:
"""Seconds a "working" animation should wait between frames, on this platform.
The single source of the app's spin cadence — every animated wait sleeps on this rather
than on its own constant, so the rate is one platform decision instead of five copies
drifting apart. Called at each tick rather than read into a module constant: a constant
would freeze whatever platform was active at *import* time, which is always the default
(``set_platform`` runs later, in the CLI callback — see :mod:`meshterm.platforms`).
Returns:
The active platform's :attr:`~meshterm.platforms.Platform.spinner_tick_s`.
"""
return get_platform().spinner_tick_s
class Spinner:
"""An animated spinner that cycles through a set of frame glyphs.
Attributes:
style: The Rich style :meth:`text` applies to the current glyph.
"""
#: The default Braille-dot frames — a smooth, single-cell rotation.
BRAILLE = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
#: A plain ASCII cycle for terminals/fonts without Braille glyphs.
LINE = "|/-\\"
def __init__(self, frames: str | None = None, *, style: str = "accent") -> None:
"""Create a spinner resting on its first frame.
Args:
frames: The glyphs to cycle through, one per animation frame. Defaults to the
active platform's cycle — :data:`BRAILLE` where effects are on, the
four-frame :data:`LINE` where they aren't. Resolved here, at construction,
so a spinner built after ``set_platform`` picks the right cycle and never
re-decides while it spins.
style: The Rich style :meth:`text` applies to the current glyph.
"""
if frames is None:
frames = self.BRAILLE if get_platform().effects else self.LINE
if not frames:
raise ValueError("a spinner needs at least one frame")
self._frames = frames
self.style = style
self._index = 0
@property
def frame(self) -> str:
"""The current frame's glyph."""
return self._frames[self._index]
@property
def frames(self) -> str:
"""The full cycle of frame glyphs, in order."""
return self._frames
def tick(self) -> None:
"""Advance to the next frame, wrapping around at the end of the cycle."""
self._index = (self._index + 1) % len(self._frames)
def reset(self) -> None:
"""Return the spinner to its first frame."""
self._index = 0
def text(self, style: str | None = None) -> Text:
"""Return the current glyph as a styled Rich :class:`~rich.text.Text`.
Args:
style: A style to use instead of the spinner's own :attr:`style`.
Returns:
The current glyph, styled.
"""
return Text(self.frame, style=self.style if style is None else style)