from multiprocessing import Process, Event
from sys import stdout
from time import sleep
from typing import Literal
from framed_text.labeled_data import AnyLabeledData
from framed_text.shorten_text import ShortenText
from framed_text.status import AnyStatus
from framed_text.utils import (
ANSIEscape,
_validate_color,
_format_col_text,
_get_terminal_width,
_get_terminal_height,
)
[docs]
class Spinner:
BUILTIN_ICONS: dict[str, list[str]] = {
"default": ['/', '-', '\\', '|'],
"cube": ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
"dots": [' ', '. ', '.. ', '...'],
"dots2": [' ', '. ', '.. ', '...', ' ..', ' .'],
}
[docs]
def __init__(self, text: str | AnyStatus | AnyLabeledData = '',
icons_id: str = "default",
done_icon: str = '',
custom_icons: list[str] | None = None,
interval: float = 0.2,
position: Literal["start", "end"] = "start",
indent: int = 1,
spinner_color: str | tuple[int, int, int] | None = "yellow",
done_color: str | tuple[int, int, int] | None = "green"):
"""
Draws a spinner on a screen
**Position**
- ``"start"``: The spinner will be placed at the start of the line.
- ``"end"``: The spinner will be placed at the end of the line.
:param text: Text to display next to the spinner. Omit for no text.
:param icons_id: ID string of one of the built-in icon sets from ``Spinner.BUILTIN_ICONS``
:param done_icon: Icon to use for when the spinner is complete
:param custom_icons: A list of strings to use as the spinner icon. Overrides ``icons_id``
:param interval: The delay between spinner icon updates.
:param position: Where the spinner should be placed on a line.
:param indent: How many spaces the line should be indented.
:param spinner_color: Color of the spinner icon while active.
:param done_color: Color for the spinner icon when stopped.
"""
# Terminal dimensions
self._term_lines: int = 0
self._term_columns: int = 0
self._term_columns: int = _get_terminal_width()
self._term_lines: int = _get_terminal_height()
# Create an event that will be used to stop the spinner
self._stop_e = Event()
# Threading
self._is_running: bool = False
self._process = None
# Defaults
self._icons_id: str = icons_id
self._interval: float = interval
self._position: Literal["start", "end"] = position
self._indent: int = indent
self._text: str | AnyStatus | AnyLabeledData = text
self._done_icon: str = done_icon
self._icons: list[str] = []
self._done_color: str | tuple[int, int, int] | None = None
self._spinner_color: str | tuple[int, int, int] | None = None
# Setup values
self.update(text=text,
icons_id=icons_id,
done_icon=done_icon,
custom_icons=custom_icons,
interval=interval,
position=position,
indent=indent,
spinner_color=spinner_color,
done_color=done_color)
[docs]
def start(self, text: str = ''):
"""
Start the spinner
:param text: Text to display next to the spinner. Omit for no text.
"""
if self._is_running:
return
if self._stop_e.is_set():
self._stop_e.clear()
if text:
self._text = ShortenText(text=text).__str__()
self._is_running = True
# Hide cursor
stdout.write(ANSIEscape.HIDE_CURSOR)
stdout.flush()
# Start thread
self._process = Process(target=self._run)
self._process.start()
[docs]
def stop(self, done_icon: str | None = None, done_color: str | tuple[int, int, int] | None = None):
"""
Stop the spinner
Optionally, allow custom done icon and color for the current spinner.
:param done_icon: Icon to use for when the spinner is complete
:param done_color: Color for the done icon
"""
if self._process is None:
return
self._is_running = False
self._stop_e.set()
# Stop process
self._process.join()
# Cleanup
self._cleanup(done_icon=done_icon, done_color=done_color)
# Show cursor
stdout.write(ANSIEscape.SHOW_CURSOR)
stdout.flush()
# Ensure enough room
print()
[docs]
def update(self, text: str | AnyStatus | AnyLabeledData | None = None,
icons_id: str | None = None,
done_icon: str | None = None,
custom_icons: list[str] | None = None,
interval: float | None = None,
position: Literal["start", "end"] | None = None,
indent: int | None = None,
spinner_color: str | tuple[int, int, int] | None = None,
done_color: str | tuple[int, int, int] | None = None):
"""
Update values for the spinner.
Some values can only be updated when the spinner is not running.
**Position**
- ``"start"``: The spinner will be placed at the start of the line.
- ``"end"``: The spinner will be placed at the end of the line.
:param text: Text to display next to the spinner. Omit for no text.
:param icons_id: ID string of one of the built-in icon sets from ``Spinner.BUILTIN_ICONS``
:param done_icon: Icon to use for when the spinner is complete
:param custom_icons: A list of strings to use as the spinner icon. Overrides ``icons_id``
:param interval: The delay between spinner icon updates.
:param position: Where the spinner should be placed on a line.
:param indent: How many spaces the line should be indented.
:param spinner_color: Color of the spinner icon while active.
:param done_color: Color for the spinner icon when stopped.
"""
if not self._is_running:
# Values not allowed to be changed while running
self._icons_id = icons_id if icons_id else self._icons_id
self._interval = interval if interval else self._interval
self._position = position if position else self._position
self._indent = indent if indent else self._indent
if spinner_color:
_validate_color(color=spinner_color)
self._spinner_color = spinner_color.lower() if isinstance(spinner_color, str) else spinner_color
# Setup icons
if custom_icons:
# Custom icons
self._icons = [icon[0] for icon in custom_icons]
elif icons_id:
# Built-in icons
if icons_id in list(self.BUILTIN_ICONS.keys()):
self._icons = self.BUILTIN_ICONS[icons_id]
else:
raise ValueError(f"Invalid Icon ID: {icons_id}\nValid Icon IDs: {list(self.BUILTIN_ICONS.keys())}")
else:
# Default icons
self._icons = self.BUILTIN_ICONS["default"]
# Values allowed to be changed while running
self._done_icon = done_icon if done_icon else self._done_icon
if done_color:
_validate_color(color=done_color)
self._done_color = done_color.lower() if isinstance(done_color, str) else done_color
# Resolve text
self._text = ShortenText(text=text).__str__() if text else self._text
def _cleanup(self, done_icon: str | None = None, done_color: str | tuple[int, int, int] | None = None):
"""
Remove or replace the spinner based on position
:param done_icon: Icon to use for when the spinner is complete
:param done_color: Color for the spinner icon when stopped.
"""
icon: str = ''
stdout.write(ANSIEscape.ERASE_LINE)
_done_color = done_color if done_color else self._done_color
_done_icon = done_icon if done_icon else self._done_icon
if self._position == "start":
if _done_icon:
icon = _format_col_text(text=f'{_done_icon[0]} ', color=_done_color)
stdout.write(f"\r{' ' * self._indent}{icon}{self._text}")
elif self._position == "end":
if _done_icon:
icon = _format_col_text(text=f' {_done_icon[0]}', color=_done_color)
stdout.write(f"\r{' ' * self._indent}{self._text}{icon}")
def _run(self):
"""
Function for ``self._process`` to run and display the spinner
"""
try:
itr: int = 0
cur_icon: str = _format_col_text(text=self._icons[itr], color=self._spinner_color)
while not self._stop_e.is_set():
# Print spinner
if self._position == "start":
stdout.write(f"\r{' ' * self._indent}{cur_icon} {self._text}")
elif self._position == "end":
stdout.write(f"\r{' ' * self._indent}{self._text} {cur_icon}")
# Move to next icon
itr = (itr + 1) % len(self._icons)
cur_icon: str = _format_col_text(text=self._icons[itr], color=self._spinner_color)
sleep(self._interval)
except (EOFError, KeyboardInterrupt):
# Clean up and show cursor
self._cleanup(done_icon='✘', done_color="red")
stdout.write(ANSIEscape.SHOW_CURSOR)
stdout.flush()