Skip to content

back to Reference (Gold) summary

Reference (Gold): python-progressbar

Pytest Summary for test tests

status count
passed 385
total 385
collected 385

Failed pytests:

Patch diff

diff --git a/progressbar/__about__.py b/progressbar/__about__.py
index c945e66..914b679 100644
--- a/progressbar/__about__.py
+++ b/progressbar/__about__.py
@@ -1,4 +1,4 @@
-"""Text progress bar library for Python.
+'''Text progress bar library for Python.

 A text progress bar is typically used to display the progress of a long
 running operation, providing a visual cue that processing is underway.
@@ -9,16 +9,17 @@ differently depending on the state of the progress bar.

 The progressbar module is very easy to use, yet very powerful. It will also
 automatically enable features like auto-resizing when the system supports it.
-"""
+'''
+
 __title__ = 'Python Progressbar'
 __package_name__ = 'progressbar2'
 __author__ = 'Rick van Hattem (Wolph)'
 __description__ = ' '.join(
-    """
+    '''
 A Python Progressbar library to provide visual (yet text based) progress to
 long running operations.
-"""
-    .strip().split())
+'''.strip().split(),
+)
 __email__ = 'wolph@wol.ph'
 __version__ = '4.4.2'
 __license__ = 'BSD'
diff --git a/progressbar/algorithms.py b/progressbar/algorithms.py
index 3698a37..bb8586e 100644
--- a/progressbar/algorithms.py
+++ b/progressbar/algorithms.py
@@ -1,42 +1,51 @@
 from __future__ import annotations
+
 import abc
 from datetime import timedelta


 class SmoothingAlgorithm(abc.ABC):
-
     @abc.abstractmethod
     def __init__(self, **kwargs):
         raise NotImplementedError

     @abc.abstractmethod
-    def update(self, new_value: float, elapsed: timedelta) ->float:
-        """Updates the algorithm with a new value and returns the smoothed
+    def update(self, new_value: float, elapsed: timedelta) -> float:
+        '''Updates the algorithm with a new value and returns the smoothed
         value.
-        """
-        pass
+        '''
+        raise NotImplementedError


 class ExponentialMovingAverage(SmoothingAlgorithm):
-    """
+    '''
     The Exponential Moving Average (EMA) is an exponentially weighted moving
     average that reduces the lag that's typically associated with a simple
     moving average. It's more responsive to recent changes in data.
-    """
+    '''

-    def __init__(self, alpha: float=0.5) ->None:
+    def __init__(self, alpha: float = 0.5) -> None:
         self.alpha = alpha
         self.value = 0

+    def update(self, new_value: float, elapsed: timedelta) -> float:
+        self.value = self.alpha * new_value + (1 - self.alpha) * self.value
+        return self.value
+

 class DoubleExponentialMovingAverage(SmoothingAlgorithm):
-    """
+    '''
     The Double Exponential Moving Average (DEMA) is essentially an EMA of an
     EMA, which reduces the lag that's typically associated with a simple EMA.
     It's more responsive to recent changes in data.
-    """
+    '''

-    def __init__(self, alpha: float=0.5) ->None:
+    def __init__(self, alpha: float = 0.5) -> None:
         self.alpha = alpha
         self.ema1 = 0
         self.ema2 = 0
+
+    def update(self, new_value: float, elapsed: timedelta) -> float:
+        self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
+        self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2
+        return 2 * self.ema1 - self.ema2
diff --git a/progressbar/bar.py b/progressbar/bar.py
index d3c579a..7a048bf 100644
--- a/progressbar/bar.py
+++ b/progressbar/bar.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+
 import abc
 import contextlib
 import itertools
@@ -11,14 +12,27 @@ import timeit
 import warnings
 from copy import deepcopy
 from datetime import datetime
+
 from python_utils import converters, types
+
 import progressbar.env
 import progressbar.terminal
 import progressbar.terminal.stream
-from . import base, utils, widgets, widgets as widgets_module
+
+from . import (
+    base,
+    utils,
+    widgets,
+    widgets as widgets_module,  # Avoid name collision
+)
 from .terminal import os_specific
+
 logger = logging.getLogger(__name__)
+
+# float also accepts integers and longs but we don't want an explicit union
+# due to type checking complexity
 NumberT = float
+
 T = types.TypeVar('T')


@@ -26,35 +40,97 @@ class ProgressBarMixinBase(abc.ABC):
     _started = False
     _finished = False
     _last_update_time: types.Optional[float] = None
+
+    #: The terminal width. This should be automatically detected but will
+    #: fall back to 80 if auto detection is not possible.
     term_width: int = 80
+    #: The widgets to render, defaults to the result of `default_widget()`
     widgets: types.MutableSequence[widgets_module.WidgetBase | str]
+    #: When going beyond the max_value, raise an error if True or silently
+    #: ignore otherwise
     max_error: bool
+    #: Prefix the progressbar with the given string
     prefix: types.Optional[str]
+    #: Suffix the progressbar with the given string
     suffix: types.Optional[str]
+    #: Justify to the left if `True` or the right if `False`
     left_justify: bool
+    #: The default keyword arguments for the `default_widgets` if no widgets
+    #: are configured
     widget_kwargs: types.Dict[str, types.Any]
+    #: Custom length function for multibyte characters such as CJK
+    # mypy and pyright can't agree on what the correct one is... so we'll
+    # need to use a helper function :(
+    # custom_len: types.Callable[['ProgressBarMixinBase', str], int]
     custom_len: types.Callable[[str], int]
+    #: The time the progress bar was started
     initial_start_time: types.Optional[datetime]
+    #: The interval to poll for updates in seconds if there are updates
     poll_interval: types.Optional[float]
+    #: The minimum interval to poll for updates in seconds even if there are
+    #: no updates
     min_poll_interval: float
+
+    #: Deprecated: The number of intervals that can fit on the screen with a
+    #: minimum of 100
     num_intervals: int = 0
+    #: Deprecated: The `next_update` is kept for compatibility with external
+    #: libs: https://github.com/WoLpH/python-progressbar/issues/207
     next_update: int = 0
+
+    #: Current progress (min_value <= value <= max_value)
     value: NumberT
+    #: Previous progress value
     previous_value: types.Optional[NumberT]
+    #: The minimum/start value for the progress bar
     min_value: NumberT
+    #: Maximum (and final) value. Beyond this value an error will be raised
+    #: unless the `max_error` parameter is `False`.
     max_value: NumberT | types.Type[base.UnknownLength]
+    #: The time the progressbar reached `max_value` or when `finish()` was
+    #: called.
     end_time: types.Optional[datetime]
+    #: The time `start()` was called or iteration started.
     start_time: types.Optional[datetime]
+    #: Seconds between `start_time` and last call to `update()`
     seconds_elapsed: float
+
+    #: Extra data for widgets with persistent state. This is used by
+    #: sampling widgets for example. Since widgets can be shared between
+    #: multiple progressbars we need to store the state with the progressbar.
     extra: types.Dict[str, types.Any]
+
+    def get_last_update_time(self) -> types.Optional[datetime]:
+        if self._last_update_time:
+            return datetime.fromtimestamp(self._last_update_time)
+        else:
+            return None
+
+    def set_last_update_time(self, value: types.Optional[datetime]):
+        if value:
+            self._last_update_time = time.mktime(value.timetuple())
+        else:
+            self._last_update_time = None
+
     last_update_time = property(get_last_update_time, set_last_update_time)

-    def __init__(self, **kwargs):
+    def __init__(self, **kwargs):  # noqa: B027
+        pass
+
+    def start(self, **kwargs):
+        self._started = True
+
+    def update(self, value=None):  # noqa: B027
         pass

+    def finish(self):  # pragma: no cover
+        self._finished = True
+
     def __del__(self):
-        if not self._finished and self._started:
-            try:
+        if not self._finished and self._started:  # pragma: no cover
+            # We're not using contextlib.suppress here because during teardown
+            # contextlib is not available anymore.
+            try:  # noqa: SIM105
                 self.finish()
             except AttributeError:
                 pass
@@ -62,6 +138,15 @@ class ProgressBarMixinBase(abc.ABC):
     def __getstate__(self):
         return self.__dict__

+    def data(self) -> types.Dict[str, types.Any]:  # pragma: no cover
+        raise NotImplementedError()
+
+    def started(self) -> bool:
+        return self._finished or self._started
+
+    def finished(self) -> bool:
+        return self._finished
+

 class ProgressBarBase(types.Iterable, ProgressBarMixinBase):
     _index_counter = itertools.count()
@@ -78,31 +163,80 @@ class ProgressBarBase(types.Iterable, ProgressBarMixinBase):


 class DefaultFdMixin(ProgressBarMixinBase):
+    # The file descriptor to write to. Defaults to `sys.stderr`
     fd: base.TextIO = sys.stderr
+    #: Set the terminal to be ANSI compatible. If a terminal is ANSI
+    #: compatible we will automatically enable `colors` and disable
+    #: `line_breaks`.
     is_ansi_terminal: bool | None = False
+    #: Whether the file descriptor is a terminal or not. This is used to
+    #: determine whether to use ANSI escape codes or not.
     is_terminal: bool | None
+    #: Whether to print line breaks. This is useful for logging the
+    #: progressbar. When disabled the current line is overwritten.
     line_breaks: bool | None = True
+    #: Specify the type and number of colors to support. Defaults to auto
+    #: detection based on the file descriptor type (i.e. interactive terminal)
+    #: environment variables such as `COLORTERM` and `TERM`. Color output can
+    #: be forced in non-interactive terminals using the
+    #: `PROGRESSBAR_ENABLE_COLORS` environment variable which can also be used
+    #: to force a specific number of colors by specifying `24bit`, `256` or
+    #: `16`.
+    #: For true (24 bit/16M) color support you can use `COLORTERM=truecolor`.
+    #: For 256 color support you can use `TERM=xterm-256color`.
+    #: For 16 colorsupport you can use `TERM=xterm`.
     enable_colors: progressbar.env.ColorSupport = progressbar.env.COLOR_SUPPORT

-    def __init__(self, fd: base.TextIO=sys.stderr, is_terminal: (bool |
-        None)=None, line_breaks: (bool | None)=None, enable_colors: (
-        progressbar.env.ColorSupport | None)=None, line_offset: int=0, **kwargs
-        ):
+    def __init__(
+            self,
+            fd: base.TextIO = sys.stderr,
+            is_terminal: bool | None = None,
+            line_breaks: bool | None = None,
+            enable_colors: progressbar.env.ColorSupport | None = None,
+            line_offset: int = 0,
+            **kwargs,
+    ):
         if fd is sys.stdout:
             fd = utils.streams.original_stdout
         elif fd is sys.stderr:
             fd = utils.streams.original_stderr
+
         fd = self._apply_line_offset(fd, line_offset)
         self.fd = fd
         self.is_ansi_terminal = progressbar.env.is_ansi_terminal(fd)
         self.is_terminal = progressbar.env.is_terminal(fd, is_terminal)
         self.line_breaks = self._determine_line_breaks(line_breaks)
         self.enable_colors = self._determine_enable_colors(enable_colors)
+
         super().__init__(**kwargs)

-    def _determine_enable_colors(self, enable_colors: (progressbar.env.
-        ColorSupport | None)) ->progressbar.env.ColorSupport:
-        """
+    def _apply_line_offset(
+            self,
+            fd: base.TextIO,
+            line_offset: int,
+    ) -> base.TextIO:
+        if line_offset:
+            return progressbar.terminal.stream.LineOffsetStreamWrapper(
+                line_offset,
+                fd,
+            )
+        else:
+            return fd
+
+    def _determine_line_breaks(self, line_breaks: bool | None) -> bool | None:
+        if line_breaks is None:
+            return progressbar.env.env_flag(
+                'PROGRESSBAR_LINE_BREAKS',
+                not self.is_terminal,
+            )
+        else:
+            return line_breaks
+
+    def _determine_enable_colors(
+            self,
+            enable_colors: progressbar.env.ColorSupport | None,
+    ) -> progressbar.env.ColorSupport:
+        '''
         Determines the color support for the progress bar.

         This method checks the `enable_colors` parameter and the environment
@@ -128,32 +262,160 @@ class DefaultFdMixin(ProgressBarMixinBase):
         Raises:
             ValueError: If `enable_colors` is not None, True, False, or an
             instance of `progressbar.env.ColorSupport`.
-        """
-        pass
+        '''
+        color_support: progressbar.env.ColorSupport
+        if enable_colors is None:
+            colors = (
+                progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'),
+                progressbar.env.env_flag('FORCE_COLOR'),
+                self.is_ansi_terminal,
+            )
+
+            for color_enabled in colors:
+                if color_enabled is not None:
+                    if color_enabled:
+                        color_support = progressbar.env.COLOR_SUPPORT
+                    else:
+                        color_support = progressbar.env.ColorSupport.NONE
+                    break
+            else:
+                color_support = progressbar.env.ColorSupport.NONE
+
+        elif enable_colors is True:
+            color_support = progressbar.env.ColorSupport.XTERM_256
+        elif enable_colors is False:
+            color_support = progressbar.env.ColorSupport.NONE
+        elif isinstance(enable_colors, progressbar.env.ColorSupport):
+            color_support = enable_colors
+        else:
+            raise ValueError(f'Invalid color support value: {enable_colors}')
+
+        return color_support
+
+    def print(self, *args: types.Any, **kwargs: types.Any) -> None:
+        print(*args, file=self.fd, **kwargs)
+
+    def start(self, **kwargs):
+        os_specific.set_console_mode()
+        super().start()
+
+    def update(self, *args: types.Any, **kwargs: types.Any) -> None:
+        ProgressBarMixinBase.update(self, *args, **kwargs)
+
+        line: str = converters.to_unicode(self._format_line())
+        if not self.enable_colors:
+            line = utils.no_color(line)
+
+        line = line.rstrip() + '\n' if self.line_breaks else '\r' + line
+
+        try:  # pragma: no cover
+            self.fd.write(line)
+        except UnicodeEncodeError:  # pragma: no cover
+            self.fd.write(types.cast(str, line.encode('ascii', 'replace')))
+
+    def finish(
+            self,
+            *args: types.Any,
+            **kwargs: types.Any,
+    ) -> None:  # pragma: no cover
+        os_specific.reset_console_mode()
+
+        if self._finished:
+            return
+
+        end = kwargs.pop('end', '\n')
+        ProgressBarMixinBase.finish(self, *args, **kwargs)
+
+        if end and not self.line_breaks:
+            self.fd.write(end)
+
+        self.fd.flush()

     def _format_line(self):
-        """Joins the widgets and justifies the line."""
-        pass
+        'Joins the widgets and justifies the line.'
+        widgets = ''.join(self._to_unicode(self._format_widgets()))

+        if self.left_justify:
+            return widgets.ljust(self.term_width)
+        else:
+            return widgets.rjust(self.term_width)
+
+    def _format_widgets(self):
+        result = []
+        expanding = []
+        width = self.term_width
+        data = self.data()
+
+        for index, widget in enumerate(self.widgets):
+            if isinstance(
+                    widget,
+                    widgets.WidgetBase,
+            ) and not widget.check_size(self):
+                continue
+            elif isinstance(widget, widgets.AutoWidthWidgetBase):
+                result.append(widget)
+                expanding.insert(0, index)
+            elif isinstance(widget, str):
+                result.append(widget)
+                width -= self.custom_len(widget)  # type: ignore
+            else:
+                widget_output = converters.to_unicode(widget(self, data))
+                result.append(widget_output)
+                width -= self.custom_len(widget_output)  # type: ignore

-class ResizableMixin(ProgressBarMixinBase):
+        count = len(expanding)
+        while expanding:
+            portion = max(int(math.ceil(width * 1.0 / count)), 0)
+            index = expanding.pop()
+            widget = result[index]
+            count -= 1
+
+            widget_output = widget(self, data, portion)
+            width -= self.custom_len(widget_output)  # type: ignore
+            result[index] = widget_output
+
+        return result

-    def __init__(self, term_width: (int | None)=None, **kwargs):
+    @classmethod
+    def _to_unicode(cls, args):
+        for arg in args:
+            yield converters.to_unicode(arg)
+
+
+class ResizableMixin(ProgressBarMixinBase):
+    def __init__(self, term_width: int | None = None, **kwargs):
         ProgressBarMixinBase.__init__(self, **kwargs)
+
         self.signal_set = False
         if term_width:
             self.term_width = term_width
-        else:
+        else:  # pragma: no cover
             with contextlib.suppress(Exception):
                 self._handle_resize()
                 import signal
-                self._prev_handle = signal.getsignal(signal.SIGWINCH)
-                signal.signal(signal.SIGWINCH, self._handle_resize)
+
+                self._prev_handle = signal.getsignal(
+                    signal.SIGWINCH  # type: ignore
+                )
+                signal.signal(
+                    signal.SIGWINCH, self._handle_resize  # type: ignore
+                )
                 self.signal_set = True

     def _handle_resize(self, signum=None, frame=None):
-        """Tries to catch resize signals sent from the terminal."""
-        pass
+        'Tries to catch resize signals sent from the terminal.'
+        w, h = utils.get_terminal_size()
+        self.term_width = w
+
+    def finish(self):  # pragma: no cover
+        ProgressBarMixinBase.finish(self)
+        if self.signal_set:
+            with contextlib.suppress(Exception):
+                import signal
+
+                signal.signal(
+                    signal.SIGWINCH, self._prev_handle  # type: ignore
+                )


 class StdRedirectMixin(DefaultFdMixin):
@@ -164,17 +426,57 @@ class StdRedirectMixin(DefaultFdMixin):
     _stdout: base.IO
     _stderr: base.IO

-    def __init__(self, redirect_stderr: bool=False, redirect_stdout: bool=
-        False, **kwargs):
+    def __init__(
+            self,
+            redirect_stderr: bool = False,
+            redirect_stdout: bool = False,
+            **kwargs,
+    ):
         DefaultFdMixin.__init__(self, **kwargs)
         self.redirect_stderr = redirect_stderr
         self.redirect_stdout = redirect_stdout
         self._stdout = self.stdout = sys.stdout
         self._stderr = self.stderr = sys.stderr

+    def start(self, *args, **kwargs):
+        if self.redirect_stdout:
+            utils.streams.wrap_stdout()

-class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
-    """The ProgressBar class which updates and prints the bar.
+        if self.redirect_stderr:
+            utils.streams.wrap_stderr()
+
+        self._stdout = utils.streams.original_stdout
+        self._stderr = utils.streams.original_stderr
+
+        self.stdout = utils.streams.stdout
+        self.stderr = utils.streams.stderr
+
+        utils.streams.start_capturing(self)
+        DefaultFdMixin.start(self, *args, **kwargs)
+
+    def update(self, value: types.Optional[float] = None):
+        if not self.line_breaks and utils.streams.needs_clear():
+            self.fd.write('\r' + ' ' * self.term_width + '\r')
+
+        utils.streams.flush()
+        DefaultFdMixin.update(self, value=value)
+
+    def finish(self, end='\n'):
+        DefaultFdMixin.finish(self, end=end)
+        utils.streams.stop_capturing(self)
+        if self.redirect_stdout:
+            utils.streams.unwrap_stdout()
+
+        if self.redirect_stderr:
+            utils.streams.unwrap_stderr()
+
+
+class ProgressBar(
+    StdRedirectMixin,
+    ResizableMixin,
+    ProgressBarBase,
+):
+    '''The ProgressBar class which updates and prints the bar.

     Args:
         min_value (int): The minimum/start value for the progress bar
@@ -245,79 +547,144 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
     the current progress bar. As a result, you have access to the
     ProgressBar's methods and attributes. Although there is nothing preventing
     you from changing the ProgressBar you should treat it as read only.
-    """
+    '''
+
     _iterable: types.Optional[types.Iterator]
+
     _DEFAULT_MAXVAL: type[base.UnknownLength] = base.UnknownLength
-    _MINIMUM_UPDATE_INTERVAL: float = 0.05
+    # update every 50 milliseconds (up to a 20 times per second)
+    _MINIMUM_UPDATE_INTERVAL: float = 0.050
     _last_update_time: types.Optional[float] = None
     paused: bool = False

-    def __init__(self, min_value: NumberT=0, max_value: (NumberT | types.
-        Type[base.UnknownLength] | None)=None, widgets: types.Optional[
-        types.Sequence[widgets_module.WidgetBase | str]]=None, left_justify:
-        bool=True, initial_value: NumberT=0, poll_interval: types.Optional[
-        float]=None, widget_kwargs: types.Optional[types.Dict[str, types.
-        Any]]=None, custom_len: types.Callable[[str], int]=utils.len_color,
-        max_error=True, prefix=None, suffix=None, variables=None,
-        min_poll_interval=None, **kwargs):
-        """Initializes a progress bar with sane defaults."""
+    def __init__(
+            self,
+            min_value: NumberT = 0,
+            max_value: NumberT | types.Type[base.UnknownLength] | None = None,
+            widgets: types.Optional[
+                types.Sequence[widgets_module.WidgetBase | str]
+            ] = None,
+            left_justify: bool = True,
+            initial_value: NumberT = 0,
+            poll_interval: types.Optional[float] = None,
+            widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None,
+            custom_len: types.Callable[[str], int] = utils.len_color,
+            max_error=True,
+            prefix=None,
+            suffix=None,
+            variables=None,
+            min_poll_interval=None,
+            **kwargs,
+    ):  # sourcery skip: low-code-quality
+        '''Initializes a progress bar with sane defaults.'''
         StdRedirectMixin.__init__(self, **kwargs)
         ResizableMixin.__init__(self, **kwargs)
         ProgressBarBase.__init__(self, **kwargs)
         if not max_value and kwargs.get('maxval') is not None:
             warnings.warn(
-                'The usage of `maxval` is deprecated, please use `max_value` instead'
-                , DeprecationWarning, stacklevel=1)
+                'The usage of `maxval` is deprecated, please use '
+                '`max_value` instead',
+                DeprecationWarning,
+                stacklevel=1,
+            )
             max_value = kwargs.get('maxval')
+
         if not poll_interval and kwargs.get('poll'):
             warnings.warn(
-                'The usage of `poll` is deprecated, please use `poll_interval` instead'
-                , DeprecationWarning, stacklevel=1)
+                'The usage of `poll` is deprecated, please use '
+                '`poll_interval` instead',
+                DeprecationWarning,
+                stacklevel=1,
+            )
             poll_interval = kwargs.get('poll')
+
         if max_value and min_value > types.cast(NumberT, max_value):
-            raise ValueError('Max value needs to be bigger than the min value')
+            raise ValueError(
+                'Max value needs to be bigger than the min value',
+            )
         self.min_value = min_value
-        self.max_value = max_value
+        # Legacy issue, `max_value` can be `None` before execution. After
+        # that it either has a value or is `UnknownLength`
+        self.max_value = max_value  # type: ignore
         self.max_error = max_error
+
+        # Only copy the widget if it's safe to copy. Most widgets are so we
+        # assume this to be true
         self.widgets = []
-        for widget in (widgets or []):
+        for widget in widgets or []:
             if getattr(widget, 'copy', True):
                 widget = deepcopy(widget)
             self.widgets.append(widget)
+
         self.prefix = prefix
         self.suffix = suffix
         self.widget_kwargs = widget_kwargs or {}
         self.left_justify = left_justify
         self.value = initial_value
         self._iterable = None
-        self.custom_len = custom_len
+        self.custom_len = custom_len  # type: ignore
         self.initial_start_time = kwargs.get('start_time')
         self.init()
+
+        # Convert a given timedelta to a floating point number as internal
+        # interval. We're not using timedelta's internally for two reasons:
+        # 1. Backwards compatibility (most important one)
+        # 2. Performance. Even though the amount of time it takes to compare a
+        # timedelta with a float versus a float directly is negligible, this
+        # comparison is run for _every_ update. With billions of updates
+        # (downloading a 1GiB file for example) this adds up.
         poll_interval = utils.deltas_to_seconds(poll_interval, default=None)
-        min_poll_interval = utils.deltas_to_seconds(min_poll_interval,
-            default=None)
-        self._MINIMUM_UPDATE_INTERVAL = utils.deltas_to_seconds(self.
-            _MINIMUM_UPDATE_INTERVAL) or self._MINIMUM_UPDATE_INTERVAL
+        min_poll_interval = utils.deltas_to_seconds(
+            min_poll_interval,
+            default=None,
+        )
+        self._MINIMUM_UPDATE_INTERVAL = (
+                utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL)
+                or self._MINIMUM_UPDATE_INTERVAL
+        )
+
+        # Note that the _MINIMUM_UPDATE_INTERVAL sets the minimum in case of
+        # low values.
         self.poll_interval = poll_interval
-        self.min_poll_interval = max(min_poll_interval or self.
-            _MINIMUM_UPDATE_INTERVAL, self._MINIMUM_UPDATE_INTERVAL, float(
-            os.environ.get('PROGRESSBAR_MINIMUM_UPDATE_INTERVAL', 0)))
+        self.min_poll_interval = max(
+            min_poll_interval or self._MINIMUM_UPDATE_INTERVAL,
+            self._MINIMUM_UPDATE_INTERVAL,
+            float(os.environ.get('PROGRESSBAR_MINIMUM_UPDATE_INTERVAL', 0)),
+        )  # type: ignore
+
+        # A dictionary of names that can be used by Variable and FormatWidget
         self.variables = utils.AttributeDict(variables or {})
         for widget in self.widgets:
-            if isinstance(widget, widgets_module.VariableMixin
-                ) and widget.name not in self.variables:
+            if (
+                    isinstance(widget, widgets_module.VariableMixin)
+                    and widget.name not in self.variables
+            ):
                 self.variables[widget.name] = None

+    @property
+    def dynamic_messages(self):  # pragma: no cover
+        return self.variables
+
+    @dynamic_messages.setter
+    def dynamic_messages(self, value):  # pragma: no cover
+        self.variables = value
+
     def init(self):
-        """
+        '''
         (re)initialize values to original state so the progressbar can be
         used (again).
-        """
-        pass
+        '''
+        self.previous_value = None
+        self.last_update_time = None
+        self.start_time = None
+        self.updates = 0
+        self.end_time = None
+        self.extra = dict()
+        self._last_update_timer = timeit.default_timer()

     @property
-    def percentage(self) ->(float | None):
-        """Return current percentage, returns None if no max_value is given.
+    def percentage(self) -> float | None:
+        '''Return current percentage, returns None if no max_value is given.

         >>> progress = ProgressBar()
         >>> progress.max_value = 10
@@ -346,11 +713,20 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
         25.0
         >>> progress.max_value = None
         >>> progress.percentage
-        """
-        pass
+        '''
+        if self.max_value is None or self.max_value is base.UnknownLength:
+            return None
+        elif self.max_value:
+            todo = self.value - self.min_value
+            total = self.max_value - self.min_value  # type: ignore
+            percentage = 100.0 * todo / total
+        else:
+            percentage = 100.0
+
+        return percentage

-    def data(self) ->types.Dict[str, types.Any]:
-        """
+    def data(self) -> types.Dict[str, types.Any]:
+        '''

         Returns:
             dict:
@@ -376,18 +752,87 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
                 - `variables`: Dictionary of user-defined variables for the
                   :py:class:`~progressbar.widgets.Variable`'s.

-        """
-        pass
+        '''
+        self._last_update_time = time.time()
+        self._last_update_timer = timeit.default_timer()
+        elapsed = self.last_update_time - self.start_time  # type: ignore
+        # For Python 2.7 and higher we have _`timedelta.total_seconds`, but we
+        # want to support older versions as well
+        total_seconds_elapsed = utils.deltas_to_seconds(elapsed)
+        return dict(
+            # The maximum value (can be None with iterators)
+            max_value=self.max_value,
+            # Start time of the widget
+            start_time=self.start_time,
+            # Last update time of the widget
+            last_update_time=self.last_update_time,
+            # End time of the widget
+            end_time=self.end_time,
+            # The current value
+            value=self.value,
+            # The previous value
+            previous_value=self.previous_value,
+            # The total update count
+            updates=self.updates,
+            # The seconds since the bar started
+            total_seconds_elapsed=total_seconds_elapsed,
+            # The seconds since the bar started modulo 60
+            seconds_elapsed=(elapsed.seconds % 60)
+                            + (elapsed.microseconds / 1000000.0),
+            # The minutes since the bar started modulo 60
+            minutes_elapsed=(elapsed.seconds / 60) % 60,
+            # The hours since the bar started modulo 24
+            hours_elapsed=(elapsed.seconds / (60 * 60)) % 24,
+            # The hours since the bar started
+            days_elapsed=(elapsed.seconds / (60 * 60 * 24)),
+            # The raw elapsed `datetime.timedelta` object
+            time_elapsed=elapsed,
+            # Percentage as a float or `None` if no max_value is available
+            percentage=self.percentage,
+            # Dictionary of user-defined
+            # :py:class:`progressbar.widgets.Variable`'s
+            variables=self.variables,
+            # Deprecated alias for `variables`
+            dynamic_messages=self.variables,
+        )
+
+    def default_widgets(self):
+        if self.max_value:
+            return [
+                widgets.Percentage(**self.widget_kwargs),
+                ' ',
+                widgets.SimpleProgress(
+                    format=f'({widgets.SimpleProgress.DEFAULT_FORMAT})',
+                    **self.widget_kwargs,
+                ),
+                ' ',
+                widgets.Bar(**self.widget_kwargs),
+                ' ',
+                widgets.Timer(**self.widget_kwargs),
+                ' ',
+                widgets.SmoothingETA(**self.widget_kwargs),
+            ]
+        else:
+            return [
+                widgets.AnimatedMarker(**self.widget_kwargs),
+                ' ',
+                widgets.BouncingBar(**self.widget_kwargs),
+                ' ',
+                widgets.Counter(**self.widget_kwargs),
+                ' ',
+                widgets.Timer(**self.widget_kwargs),
+            ]

     def __call__(self, iterable, max_value=None):
-        """Use a ProgressBar to iterate through an iterable."""
+        'Use a ProgressBar to iterate through an iterable.'
         if max_value is not None:
             self.max_value = max_value
         elif self.max_value is None:
             try:
                 self.max_value = len(iterable)
-            except TypeError:
+            except TypeError:  # pragma: no cover
                 self.max_value = base.UnknownLength
+
         self._iterable = iter(iterable)
         return self

@@ -396,18 +841,20 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):

     def __next__(self):
         try:
-            if self._iterable is None:
+            if self._iterable is None:  # pragma: no cover
                 value = self.value
             else:
                 value = next(self._iterable)
+
             if self.start_time is None:
                 self.start()
             else:
                 self.update(self.value + 1)
+
         except StopIteration:
             self.finish()
             raise
-        except GeneratorExit:
+        except GeneratorExit:  # pragma: no cover
             self.finish(dirty=True)
             raise
         else:
@@ -418,22 +865,103 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):

     def __enter__(self):
         return self
+
+    # Create an alias so that Python 2.x won't complain about not being
+    # an iterator.
     next = __next__

     def __iadd__(self, value):
-        """Updates the ProgressBar by adding a new value."""
+        'Updates the ProgressBar by adding a new value.'
         return self.increment(value)

+    def increment(self, value=1, *args, **kwargs):
+        self.update(self.value + value, *args, **kwargs)
+        return self
+
     def _needs_update(self):
-        """Returns whether the ProgressBar should redraw the line."""
-        pass
+        'Returns whether the ProgressBar should redraw the line.'
+        if self.paused:
+            return False
+        delta = timeit.default_timer() - self._last_update_timer
+        if delta < self.min_poll_interval:
+            # Prevent updating too often
+            return False
+        elif self.poll_interval and delta > self.poll_interval:
+            # Needs to redraw timers and animations
+            return True
+
+        # Update if value increment is not large enough to
+        # add more bars to progressbar (according to current
+        # terminal width)
+        with contextlib.suppress(Exception):
+            divisor: float = self.max_value / self.term_width  # type: ignore
+            value_divisor = self.value // divisor  # type: ignore
+            pvalue_divisor = self.previous_value // divisor  # type: ignore
+            if value_divisor != pvalue_divisor:
+                return True
+        # No need to redraw yet
+        return False

     def update(self, value=None, force=False, **kwargs):
-        """Updates the ProgressBar to a new value."""
-        pass
+        'Updates the ProgressBar to a new value.'
+        if self.start_time is None:
+            self.start()
+
+        if (
+                value is not None
+                and value is not base.UnknownLength
+                and isinstance(value, (int, float))
+        ):
+            if self.max_value is base.UnknownLength:
+                # Can't compare against unknown lengths so just update
+                pass
+            elif self.min_value > value:  # type: ignore
+                raise ValueError(
+                    f'Value {value} is too small. Should be '
+                    f'between {self.min_value} and {self.max_value}',
+                )
+            elif self.max_value < value:  # type: ignore
+                if self.max_error:
+                    raise ValueError(
+                        f'Value {value} is too large. Should be between '
+                        f'{self.min_value} and {self.max_value}',
+                    )
+                else:
+                    value = self.max_value
+
+            self.previous_value = self.value
+            self.value = value  # type: ignore
+
+        # Save the updated values for dynamic messages
+        variables_changed = self._update_variables(kwargs)
+
+        if self._needs_update() or variables_changed or force:
+            self._update_parents(value)
+
+    def _update_variables(self, kwargs):
+        variables_changed = False
+        for key, value_ in kwargs.items():
+            if key not in self.variables:
+                raise TypeError(
+                    'update() got an unexpected variable name as argument '
+                    '{key!r}',
+                )
+            elif self.variables[key] != value_:
+                self.variables[key] = kwargs[key]
+                variables_changed = True
+        return variables_changed
+
+    def _update_parents(self, value):
+        self.updates += 1
+        ResizableMixin.update(self, value=value)
+        ProgressBarBase.update(self, value=value)
+        StdRedirectMixin.update(self, value=value)  # type: ignore
+
+        # Only flush if something was actually written
+        self.fd.flush()

     def start(self, max_value=None, init=True, *args, **kwargs):
-        """Starts measuring time, and prints the bar at 0%.
+        '''Starts measuring time, and prints the bar at 0%.

         It returns self so you can use it like this:

@@ -449,11 +977,83 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
         ...    pbar.update(i+1)
         ...
         >>> pbar.finish()
-        """
-        pass
+        '''
+        if init:
+            self.init()
+
+        # Prevent multiple starts
+        if self.start_time is not None:  # pragma: no cover
+            return self
+
+        if max_value is not None:
+            self.max_value = max_value
+
+        if self.max_value is None:
+            self.max_value = self._DEFAULT_MAXVAL
+
+        StdRedirectMixin.start(self, max_value=max_value)
+        ResizableMixin.start(self, max_value=max_value)
+        ProgressBarBase.start(self, max_value=max_value)
+
+        # Constructing the default widgets is only done when we know max_value
+        if not self.widgets:
+            self.widgets = self.default_widgets()
+
+        self._init_prefix()
+        self._init_suffix()
+        self._calculate_poll_interval()
+        self._verify_max_value()
+
+        now = datetime.now()
+        self.start_time = self.initial_start_time or now
+        self.last_update_time = now
+        self._last_update_timer = timeit.default_timer()
+        self.update(self.min_value, force=True)
+
+        return self
+
+    def _init_suffix(self):
+        if self.suffix:
+            self.widgets.append(
+                widgets.FormatLabel(self.suffix, new_style=True),
+            )
+            # Unset the suffix variable after applying so an extra start()
+            # won't keep copying it
+            self.suffix = None
+
+    def _init_prefix(self):
+        if self.prefix:
+            self.widgets.insert(
+                0,
+                widgets.FormatLabel(self.prefix, new_style=True),
+            )
+            # Unset the prefix variable after applying so an extra start()
+            # won't keep copying it
+            self.prefix = None
+
+    def _verify_max_value(self):
+        if (
+                self.max_value is not base.UnknownLength
+                and self.max_value is not None
+                and self.max_value < 0  # type: ignore
+        ):
+            raise ValueError('max_value out of range, got %r' % self.max_value)
+
+    def _calculate_poll_interval(self) -> None:
+        self.num_intervals = max(100, self.term_width)
+        for widget in self.widgets:
+            interval: int | float | None = utils.deltas_to_seconds(
+                getattr(widget, 'INTERVAL', None),
+                default=None,
+            )
+            if interval is not None:
+                self.poll_interval = min(
+                    self.poll_interval or interval,
+                    interval,
+                )

     def finish(self, end='\n', dirty=False):
-        """
+        '''
         Puts the ProgressBar bar in the finished state.

         Also flushes and disables output buffering if this was the last
@@ -464,27 +1064,70 @@ class ProgressBar(StdRedirectMixin, ResizableMixin, ProgressBarBase):
                 newline
             dirty (bool): When True the progressbar kept the current state and
                 won't be set to 100 percent
-        """
-        pass
+        '''
+        if not dirty:
+            self.end_time = datetime.now()
+            self.update(self.max_value, force=True)
+
+        StdRedirectMixin.finish(self, end=end)
+        ResizableMixin.finish(self)
+        ProgressBarBase.finish(self)

     @property
     def currval(self):
-        """
+        '''
         Legacy method to make progressbar-2 compatible with the original
         progressbar package.
-        """
-        pass
+        '''
+        warnings.warn(
+            'The usage of `currval` is deprecated, please use '
+            '`value` instead',
+            DeprecationWarning,
+            stacklevel=1,
+        )
+        return self.value


 class DataTransferBar(ProgressBar):
-    """A progress bar with sensible defaults for downloads etc.
+    '''A progress bar with sensible defaults for downloads etc.

     This assumes that the values its given are numbers of bytes.
-    """
+    '''
+
+    def default_widgets(self):
+        if self.max_value:
+            return [
+                widgets.Percentage(),
+                ' of ',
+                widgets.DataSize('max_value'),
+                ' ',
+                widgets.Bar(),
+                ' ',
+                widgets.Timer(),
+                ' ',
+                widgets.SmoothingETA(),
+            ]
+        else:
+            return [
+                widgets.AnimatedMarker(),
+                ' ',
+                widgets.DataSize(),
+                ' ',
+                widgets.Timer(),
+            ]


 class NullBar(ProgressBar):
-    """
+    '''
     Progress bar that does absolutely nothing. Useful for single verbosity
     flags.
-    """
+    '''
+
+    def start(self, *args, **kwargs):
+        return self
+
+    def update(self, *args, **kwargs):
+        return self
+
+    def finish(self, *args, **kwargs):
+        return self
diff --git a/progressbar/base.py b/progressbar/base.py
index 9c7fad7..f3f2ef5 100644
--- a/progressbar/base.py
+++ b/progressbar/base.py
@@ -2,14 +2,14 @@ from python_utils import types


 class FalseMeta(type):
-
     @classmethod
-    def __bool__(cls):
+    def __bool__(cls):  # pragma: no cover
         return False

     @classmethod
-    def __cmp__(cls, other):
+    def __cmp__(cls, other):  # pragma: no cover
         return -1
+
     __nonzero__ = __bool__


@@ -21,10 +21,11 @@ class Undefined(metaclass=FalseMeta):
     pass


-try:
-    IO = types.IO
-    TextIO = types.TextIO
-except AttributeError:
-    from typing.io import IO, TextIO
+try:  # pragma: no cover
+    IO = types.IO  # type: ignore
+    TextIO = types.TextIO  # type: ignore
+except AttributeError:  # pragma: no cover
+    from typing.io import IO, TextIO  # type: ignore
+
 assert IO is not None
 assert TextIO is not None
diff --git a/progressbar/env.py b/progressbar/env.py
index b58929e..54e3729 100644
--- a/progressbar/env.py
+++ b/progressbar/env.py
@@ -1,25 +1,41 @@
 from __future__ import annotations
+
 import contextlib
 import enum
 import os
 import re
 import typing
+
 from . import base


+@typing.overload
+def env_flag(name: str, default: bool) -> bool: ...
+
+
+@typing.overload
+def env_flag(name: str, default: bool | None = None) -> bool | None: ...
+
+
 def env_flag(name, default=None):
-    """
+    '''
     Accepts environt variables formatted as y/n, yes/no, 1/0, true/false,
     on/off, and returns it as a boolean.

     If the environment variable is not defined, or has an unknown value,
     returns `default`
-    """
-    pass
+    '''
+    v = os.getenv(name)
+    if v and v.lower() in ('y', 'yes', 't', 'true', 'on', '1'):
+        return True
+    if v and v.lower() in ('n', 'no', 'f', 'false', 'off', '0'):
+        return False
+    return default


 class ColorSupport(enum.IntEnum):
-    """Color support for the terminal."""
+    '''Color support for the terminal.'''
+
     NONE = 0
     XTERM = 16
     XTERM_256 = 256
@@ -28,7 +44,7 @@ class ColorSupport(enum.IntEnum):

     @classmethod
     def from_env(cls):
-        """Get the color support from the environment.
+        '''Get the color support from the environment.

         If any of the environment variables contain `24bit` or `truecolor`,
         we will enable true color/24 bit support. If they contain `256`, we
@@ -40,15 +56,131 @@ class ColorSupport(enum.IntEnum):

         Note that the highest available value will be used! Having
         `COLORTERM=truecolor` will override `TERM=xterm-256color`.
-        """
-        pass
+        '''
+        variables = (
+            'FORCE_COLOR',
+            'PROGRESSBAR_ENABLE_COLORS',
+            'COLORTERM',
+            'TERM',
+        )
+
+        if JUPYTER:
+            # Jupyter notebook always supports true color.
+            return cls.XTERM_TRUECOLOR
+        elif os.name == 'nt':
+            # We can't reliably detect true color support on Windows, so we
+            # will assume it is supported if the console is configured to
+            # support it.
+            from .terminal.os_specific import windows
+
+            if (
+                windows.get_console_mode()
+                & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT
+            ):
+                return cls.XTERM_TRUECOLOR
+            else:
+                return cls.WINDOWS  # pragma: no cover
+
+        support = cls.NONE
+        for variable in variables:
+            value = os.environ.get(variable)
+            if value is None:
+                continue
+            elif value in {'truecolor', '24bit'}:
+                # Truecolor support, we don't need to check anything else.
+                support = cls.XTERM_TRUECOLOR
+                break
+            elif '256' in value:
+                support = max(cls.XTERM_256, support)
+            elif value == 'xterm':
+                support = max(cls.XTERM, support)
+
+        return support


+def is_ansi_terminal(
+    fd: base.IO,
+    is_terminal: bool | None = None,
+) -> bool | None:  # pragma: no cover
+    if is_terminal is None:
+        # Jupyter Notebooks support progress bars
+        if JUPYTER:
+            is_terminal = True
+        # This works for newer versions of pycharm only. With older versions
+        # there is no way to check.
+        elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get(
+            'PYTEST_CURRENT_TEST'
+        ):
+            is_terminal = True
+
+    if is_terminal is None:
+        # check if we are writing to a terminal or not. typically a file object
+        # is going to return False if the instance has been overridden and
+        # isatty has not been defined we have no way of knowing so we will not
+        # use ansi.  ansi terminals will typically define one of the 2
+        # environment variables.
+        with contextlib.suppress(Exception):
+            is_tty = fd.isatty()
+            # Try and match any of the huge amount of Linux/Unix ANSI consoles
+            if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')):
+                is_terminal = True
+            # ANSICON is a Windows ANSI compatible console
+            elif 'ANSICON' in os.environ:
+                is_terminal = True
+            elif os.name == 'nt':
+                from .terminal.os_specific import windows
+
+                return bool(
+                    windows.get_console_mode()
+                    & windows.WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT,
+                )
+            else:
+                is_terminal = None
+
+    return is_terminal
+
+
+def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool | None:
+    if is_terminal is None:
+        # Full ansi support encompasses what we expect from a terminal
+        is_terminal = is_ansi_terminal(fd) or None
+
+    if is_terminal is None:
+        # Allow a environment variable override
+        is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None)
+
+    if is_terminal is None:  # pragma: no cover
+        # Bare except because a lot can go wrong on different systems. If we do
+        # get a TTY we know this is a valid terminal
+        try:
+            is_terminal = fd.isatty()
+        except Exception:
+            is_terminal = False
+
+    return is_terminal
+
+
+# Enable Windows full color mode if possible
 if os.name == 'nt':
     pass
-JUPYTER = bool(os.environ.get('JUPYTER_COLUMNS') or os.environ.get(
-    'JUPYTER_LINES') or os.environ.get('JPY_PARENT_PID'))
+
+    # os_specific.set_console_mode()
+
+JUPYTER = bool(
+    os.environ.get('JUPYTER_COLUMNS')
+    or os.environ.get('JUPYTER_LINES')
+    or os.environ.get('JPY_PARENT_PID')
+)
 COLOR_SUPPORT = ColorSupport.from_env()
-ANSI_TERMS = ('([xe]|bv)term', '(sco)?ansi', 'cygwin', 'konsole', 'linux',
-    'rxvt', 'screen', 'tmux', 'vt(10[02]|220|320)')
+ANSI_TERMS = (
+    '([xe]|bv)term',
+    '(sco)?ansi',
+    'cygwin',
+    'konsole',
+    'linux',
+    'rxvt',
+    'screen',
+    'tmux',
+    'vt(10[02]|220|320)',
+)
 ANSI_TERM_RE = re.compile(f"^({'|'.join(ANSI_TERMS)})", re.IGNORECASE)
diff --git a/progressbar/multi.py b/progressbar/multi.py
index bcf8152..ae3dd23 100644
--- a/progressbar/multi.py
+++ b/progressbar/multi.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+
 import enum
 import io
 import itertools
@@ -9,14 +10,17 @@ import time
 import timeit
 import typing
 from datetime import timedelta
+
 import python_utils
+
 from . import bar, terminal
 from .terminal import stream
+
 SortKeyFunc = typing.Callable[[bar.ProgressBar], typing.Any]


 class SortKey(str, enum.Enum):
-    """
+    '''
     Sort keys for the MultiBar.

     This is a string enum, so you can use any
@@ -26,7 +30,8 @@ class SortKey(str, enum.Enum):
     progressbars. This means that sorting by dynamic attributes such as
     `value` might result in more rendering which can have a small performance
     impact.
-    """
+    '''
+
     CREATED = 'index'
     LABEL = 'label'
     VALUE = 'value'
@@ -36,15 +41,31 @@ class SortKey(str, enum.Enum):
 class MultiBar(typing.Dict[str, bar.ProgressBar]):
     fd: typing.TextIO
     _buffer: io.StringIO
+
+    #: The format for the label to append/prepend to the progressbar
     label_format: str
+    #: Automatically prepend the label to the progressbars
     prepend_label: bool
+    #: Automatically append the label to the progressbars
     append_label: bool
+    #: If `initial_format` is `None`, the progressbar rendering is used
+    # which will *start* the progressbar. That means the progressbar will
+    # have no knowledge of your data and will run as an infinite progressbar.
     initial_format: str | None
+    #: If `finished_format` is `None`, the progressbar rendering is used.
     finished_format: str | None
+
+    #: The multibar updates at a fixed interval regardless of the progressbar
+    # updates
     update_interval: float
     remove_finished: float | None
+
+    #: The kwargs passed to the progressbar constructor
     progressbar_kwargs: dict[str, typing.Any]
+
+    #: The progressbar sorting key function
     sort_keyfunc: SortKeyFunc
+
     _previous_output: list[str]
     _finished_at: dict[bar.ProgressBar, float]
     _labeled: set[bar.ProgressBar]
@@ -53,56 +74,80 @@ class MultiBar(typing.Dict[str, bar.ProgressBar]):
     _thread_finished: threading.Event = threading.Event()
     _thread_closed: threading.Event = threading.Event()

-    def __init__(self, bars: (typing.Iterable[tuple[str, bar.ProgressBar]] |
-        None)=None, fd: typing.TextIO=sys.stderr, prepend_label: bool=True,
-        append_label: bool=False, label_format='{label:20.20} ',
-        initial_format: (str | None)='{label:20.20} Not yet started',
-        finished_format: (str | None)=None, update_interval: float=1 / 60.0,
-        show_initial: bool=True, show_finished: bool=True, remove_finished:
-        (timedelta | float)=timedelta(seconds=3600), sort_key: (str |
-        SortKey)=SortKey.CREATED, sort_reverse: bool=True, sort_keyfunc: (
-        SortKeyFunc | None)=None, **progressbar_kwargs):
+    def __init__(
+        self,
+        bars: typing.Iterable[tuple[str, bar.ProgressBar]] | None = None,
+        fd: typing.TextIO = sys.stderr,
+        prepend_label: bool = True,
+        append_label: bool = False,
+        label_format='{label:20.20} ',
+        initial_format: str | None = '{label:20.20} Not yet started',
+        finished_format: str | None = None,
+        update_interval: float = 1 / 60.0,  # 60fps
+        show_initial: bool = True,
+        show_finished: bool = True,
+        remove_finished: timedelta | float = timedelta(seconds=3600),
+        sort_key: str | SortKey = SortKey.CREATED,
+        sort_reverse: bool = True,
+        sort_keyfunc: SortKeyFunc | None = None,
+        **progressbar_kwargs,
+    ):
         self.fd = fd
+
         self.prepend_label = prepend_label
         self.append_label = append_label
         self.label_format = label_format
         self.initial_format = initial_format
         self.finished_format = finished_format
+
         self.update_interval = update_interval
+
         self.show_initial = show_initial
         self.show_finished = show_finished
         self.remove_finished = python_utils.delta_to_seconds_or_none(
-            remove_finished)
+            remove_finished,
+        )
+
         self.progressbar_kwargs = progressbar_kwargs
+
         if sort_keyfunc is None:
             sort_keyfunc = operator.attrgetter(sort_key)
+
         self.sort_keyfunc = sort_keyfunc
         self.sort_reverse = sort_reverse
+
         self._labeled = set()
         self._finished_at = {}
         self._previous_output = []
         self._buffer = io.StringIO()
+
         super().__init__(bars or {})

     def __setitem__(self, key: str, bar: bar.ProgressBar):
-        """Add a progressbar to the multibar."""
-        if bar.label != key or not key:
+        '''Add a progressbar to the multibar.'''
+        if bar.label != key or not key:  # pragma: no branch
             bar.label = key
             bar.fd = stream.LastLineStream(self.fd)
             bar.paused = True
-            bar.print = self.print
+            # Essentially `bar.print = self.print`, but `mypy` doesn't
+            # like that
+            bar.print = self.print  # type: ignore
+
+        # Just in case someone is using a progressbar with a custom
+        # constructor and forgot to call the super constructor
         if bar.index == -1:
             bar.index = next(bar._index_counter)
+
         super().__setitem__(key, bar)

     def __delitem__(self, key):
-        """Remove a progressbar from the multibar."""
+        '''Remove a progressbar from the multibar.'''
         super().__delitem__(key)
         self._finished_at.pop(key, None)
         self._labeled.discard(key)

     def __getitem__(self, key):
-        """Get (and create if needed) a progressbar from the multibar."""
+        '''Get (and create if needed) a progressbar from the multibar.'''
         try:
             return super().__getitem__(key)
         except KeyError:
@@ -110,13 +155,132 @@ class MultiBar(typing.Dict[str, bar.ProgressBar]):
             self[key] = progress
             return progress

-    def render(self, flush: bool=True, force: bool=False):
-        """Render the multibar to the given stream."""
-        pass
+    def _label_bar(self, bar: bar.ProgressBar):
+        if bar in self._labeled:  # pragma: no branch
+            return
+
+        assert bar.widgets, 'Cannot prepend label to empty progressbar'
+
+        if self.prepend_label:  # pragma: no branch
+            self._labeled.add(bar)
+            bar.widgets.insert(0, self.label_format.format(label=bar.label))
+
+        if self.append_label and bar not in self._labeled:  # pragma: no branch
+            self._labeled.add(bar)
+            bar.widgets.append(self.label_format.format(label=bar.label))
+
+    def render(self, flush: bool = True, force: bool = False):
+        '''Render the multibar to the given stream.'''
+        now = timeit.default_timer()
+        expired = now - self.remove_finished if self.remove_finished else None
+
+        # sourcery skip: list-comprehension
+        output: list[str] = []
+        for bar_ in self.get_sorted_bars():
+            if not bar_.started() and not self.show_initial:
+                continue

-    def print(self, *args, end='\n', offset=None, flush=True, clear=True,
-        **kwargs):
-        """
+            output.extend(
+                iter(self._render_bar(bar_, expired=expired, now=now)),
+            )
+
+        with self._print_lock:
+            # Clear the previous output if progressbars have been removed
+            for i in range(len(output), len(self._previous_output)):
+                self._buffer.write(
+                    terminal.clear_line(i + 1),
+                )  # pragma: no cover
+
+            # Add empty lines to the end of the output if progressbars have
+            # been added
+            for _ in range(len(self._previous_output), len(output)):
+                # Adding a new line so we don't overwrite previous output
+                self._buffer.write('\n')
+
+            for i, (previous, current) in enumerate(
+                itertools.zip_longest(
+                    self._previous_output,
+                    output,
+                    fillvalue='',
+                ),
+            ):
+                if previous != current or force:  # pragma: no branch
+                    self.print(
+                        '\r' + current.strip(),
+                        offset=i + 1,
+                        end='',
+                        clear=False,
+                        flush=False,
+                    )
+
+            self._previous_output = output
+
+            if flush:  # pragma: no branch
+                self.flush()
+
+    def _render_bar(
+        self,
+        bar_: bar.ProgressBar,
+        now,
+        expired,
+    ) -> typing.Iterable[str]:
+        def update(force=True, write=True):  # pragma: no cover
+            self._label_bar(bar_)
+            bar_.update(force=force)
+            if write:
+                yield typing.cast(stream.LastLineStream, bar_.fd).line
+
+        if bar_.finished():
+            yield from self._render_finished_bar(bar_, now, expired, update)
+
+        elif bar_.started():
+            update()
+        else:
+            if self.initial_format is None:
+                bar_.start()
+                update()
+            else:
+                yield self.initial_format.format(label=bar_.label)
+
+    def _render_finished_bar(
+        self,
+        bar_: bar.ProgressBar,
+        now,
+        expired,
+        update,
+    ) -> typing.Iterable[str]:
+        if bar_ not in self._finished_at:
+            self._finished_at[bar_] = now
+            # Force update to get the finished format
+            update(write=False)
+
+        if (
+            self.remove_finished
+            and expired is not None
+            and expired >= self._finished_at[bar_]
+        ):
+            del self[bar_.label]
+            return
+
+        if not self.show_finished:
+            return
+
+        if bar_.finished():  # pragma: no branch
+            if self.finished_format is None:
+                update(force=False)
+            else:  # pragma: no cover
+                yield self.finished_format.format(label=bar_.label)
+
+    def print(
+        self,
+        *args,
+        end='\n',
+        offset=None,
+        flush=True,
+        clear=True,
+        **kwargs,
+    ):
+        '''
         Print to the progressbar stream without overwriting the progressbars.

         Args:
@@ -126,15 +290,80 @@ class MultiBar(typing.Dict[str, bar.ProgressBar]):
             flush: Whether to flush the output to the stream
             clear: If True, the line will be cleared before printing.
             **kwargs: Additional keyword arguments to pass to print
-        """
-        pass
+        '''
+        with self._print_lock:
+            if offset is None:
+                offset = len(self._previous_output)
+
+            if not clear:
+                self._buffer.write(terminal.PREVIOUS_LINE(offset))
+
+            if clear:
+                self._buffer.write(terminal.PREVIOUS_LINE(offset))
+                self._buffer.write(terminal.CLEAR_LINE_ALL())
+
+            print(*args, **kwargs, file=self._buffer, end=end)
+
+            if clear:
+                self._buffer.write(terminal.CLEAR_SCREEN_TILL_END())
+                for line in self._previous_output:
+                    self._buffer.write(line.strip())
+                    self._buffer.write('\n')
+
+            else:
+                self._buffer.write(terminal.NEXT_LINE(offset))
+
+            if flush:
+                self.flush()
+
+    def flush(self):
+        self.fd.write(self._buffer.getvalue())
+        self._buffer.truncate(0)
+        self.fd.flush()

     def run(self, join=True):
-        """
+        '''
         Start the multibar render loop and run the progressbars until they
         have force _thread_finished.
-        """
-        pass
+        '''
+        while not self._thread_finished.is_set():  # pragma: no branch
+            self.render()
+            time.sleep(self.update_interval)
+
+            if join or self._thread_closed.is_set():
+                # If the thread is closed, we need to check if the progressbars
+                # have finished. If they have, we can exit the loop
+                for bar_ in self.values():  # pragma: no cover
+                    if not bar_.finished():
+                        break
+                else:
+                    # Render one last time to make sure the progressbars are
+                    # correctly finished
+                    self.render(force=True)
+                    return
+
+    def start(self):
+        assert not self._thread, 'Multibar already started'
+        self._thread_closed.set()
+        self._thread = threading.Thread(target=self.run, args=(False,))
+        self._thread.start()
+
+    def join(self, timeout=None):
+        if self._thread is not None:
+            self._thread_closed.set()
+            self._thread.join(timeout=timeout)
+            self._thread = None
+
+    def stop(self, timeout: float | None = None):
+        self._thread_finished.set()
+        self.join(timeout=timeout)
+
+    def get_sorted_bars(self):
+        return sorted(
+            self.values(),
+            key=self.sort_keyfunc,
+            reverse=self.sort_reverse,
+        )

     def __enter__(self):
         self.start()
diff --git a/progressbar/shortcuts.py b/progressbar/shortcuts.py
index de13ddf..b16f19a 100644
--- a/progressbar/shortcuts.py
+++ b/progressbar/shortcuts.py
@@ -1 +1,22 @@
 from . import bar
+
+
+def progressbar(
+    iterator,
+    min_value=0,
+    max_value=None,
+    widgets=None,
+    prefix=None,
+    suffix=None,
+    **kwargs,
+):
+    progressbar = bar.ProgressBar(
+        min_value=min_value,
+        max_value=max_value,
+        widgets=widgets,
+        prefix=prefix,
+        suffix=suffix,
+        **kwargs,
+    )
+
+    yield from progressbar(iterator)
diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py
index 6c22ca8..895887b 100644
--- a/progressbar/terminal/base.py
+++ b/progressbar/terminal/base.py
@@ -1,15 +1,25 @@
 from __future__ import annotations
+
 import abc
 import collections
 import colorsys
 import enum
 import threading
 from collections import defaultdict
+
+# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the
+# `types` module
 from typing import ClassVar
+
 from python_utils import converters, types
-from .. import base as pbase, env
+
+from .. import (
+    base as pbase,
+    env,
+)
 from .os_specific import getch
-ESC = '\x1b'
+
+ESC = '\x1B'


 class CSI:
@@ -21,63 +31,153 @@ class CSI:
         self._default_args = default_args

     def __call__(self, *args):
-        return self._template.format(args=';'.join(map(str, args or self.
-            _default_args)), code=self._code)
+        return self._template.format(
+            args=';'.join(map(str, args or self._default_args)),
+            code=self._code,
+        )

     def __str__(self):
         return self()


 class CSINoArg(CSI):
-
     def __call__(self):
         return super().__call__()


+#: Cursor Position [row;column] (default = [1,1])
 CUP = CSI('H', 1, 1)
+
+#: Cursor Up Ps Times (default = 1) (CUU)
 UP = CSI('A', 1)
+
+#: Cursor Down Ps Times (default = 1) (CUD)
 DOWN = CSI('B', 1)
+
+#: Cursor Forward Ps Times (default = 1) (CUF)
 RIGHT = CSI('C', 1)
+
+#: Cursor Backward Ps Times (default = 1) (CUB)
 LEFT = CSI('D', 1)
+
+#: Cursor Next Line Ps Times (default = 1) (CNL)
+#: Same as Cursor Down Ps Times
 NEXT_LINE = CSI('E', 1)
+
+#: Cursor Preceding Line Ps Times (default = 1) (CPL)
+#: Same as Cursor Up Ps Times
 PREVIOUS_LINE = CSI('F', 1)
+
+#: Cursor Character Absolute  [column] (default = [row,1]) (CHA)
 COLUMN = CSI('G', 1)
+
+#: Erase in Display (ED)
 CLEAR_SCREEN = CSI('J', 0)
+
+#: Erase till end of screen
 CLEAR_SCREEN_TILL_END = CSINoArg('0J')
+
+#: Erase till start of screen
 CLEAR_SCREEN_TILL_START = CSINoArg('1J')
+
+#: Erase whole screen
 CLEAR_SCREEN_ALL = CSINoArg('2J')
+
+#: Erase whole screen and history
 CLEAR_SCREEN_ALL_AND_HISTORY = CSINoArg('3J')
+
+#: Erase in Line (EL)
 CLEAR_LINE_ALL = CSI('K')
+
+#: Erase in Line from Cursor to End of Line (default)
 CLEAR_LINE_RIGHT = CSINoArg('0K')
+
+#: Erase in Line from Cursor to Beginning of Line
 CLEAR_LINE_LEFT = CSINoArg('1K')
+
+#: Erase Line containing Cursor
 CLEAR_LINE = CSINoArg('2K')
+
+#: Scroll up Ps lines (default = 1) (SU)
+#: Scroll down Ps lines (default = 1) (SD)
 SCROLL_UP = CSI('S')
 SCROLL_DOWN = CSI('T')
+
+#: Save Cursor Position (SCP)
 SAVE_CURSOR = CSINoArg('s')
+
+#: Restore Cursor Position (RCP)
 RESTORE_CURSOR = CSINoArg('u')
+
+#: Cursor Visibility (DECTCEM)
 HIDE_CURSOR = CSINoArg('?25l')
 SHOW_CURSOR = CSINoArg('?25h')


-class _CPR(str):
+#
+# UP = CSI + '{n}A'  # Cursor Up
+# DOWN = CSI + '{n}B'  # Cursor Down
+# RIGHT = CSI + '{n}C'  # Cursor Forward
+# LEFT = CSI + '{n}D'  # Cursor Backward
+# NEXT = CSI + '{n}E'  # Cursor Next Line
+# PREV = CSI + '{n}F'  # Cursor Previous Line
+# MOVE_COLUMN = CSI + '{n}G'  # Cursor Horizontal Absolute
+# MOVE = CSI + '{row};{column}H'  # Cursor Position [row;column] (default = [
+# 1,1])
+#
+# CLEAR = CSI + '{n}J'  # Clear (part of) the screen
+# CLEAR_BOTTOM = CLEAR.format(n=0)  # Clear from cursor to end of screen
+# CLEAR_TOP = CLEAR.format(n=1)  # Clear from cursor to beginning of screen
+# CLEAR_SCREEN = CLEAR.format(n=2)  # Clear Screen
+# CLEAR_WIPE = CLEAR.format(n=3)  # Clear Screen and scrollback buffer
+#
+# CLEAR_LINE = CSI + '{n}K'  # Erase in Line
+# CLEAR_LINE_RIGHT = CLEAR_LINE.format(n=0)  # Clear from cursor to end of line
+# CLEAR_LINE_LEFT = CLEAR_LINE.format(n=1)  # Clear from cursor to beginning
+# of line
+# CLEAR_LINE_ALL = CLEAR_LINE.format(n=2)  # Clear Line
+
+
+def clear_line(n):
+    return UP(n) + CLEAR_LINE_ALL() + DOWN(n)
+
+
+# Report Cursor Position (CPR), response = [row;column] as row;columnR
+class _CPR(str):  # pragma: no cover
     _response_lock = threading.Lock()

-    def __call__(self, stream) ->tuple[int, int]:
+    def __call__(self, stream) -> tuple[int, int]:
         res: str = ''
+
         with self._response_lock:
             stream.write(str(self))
             stream.flush()
+
             while not res.endswith('R'):
                 char = getch()
+
                 if char is not None:
                     res += char
+
             res_list = res[2:-1].split(';')
-            res_list = tuple(int(item) if item.isdigit() else item for item in
-                res_list)
+
+            res_list = tuple(
+                int(item) if item.isdigit() else item for item in res_list
+            )
+
             if len(res_list) == 1:
                 return types.cast(types.Tuple[int, int], res_list[0])
+
             return types.cast(types.Tuple[int, int], tuple(res_list))

+    def row(self, stream):
+        row, _ = self(stream)
+        return row
+
+    def column(self, stream):
+        _, column = self(stream)
+        return column
+

 class WindowsColors(enum.Enum):
     BLACK = 0, 0, 0
@@ -99,7 +199,7 @@ class WindowsColors(enum.Enum):

     @staticmethod
     def from_rgb(rgb: types.Tuple[int, int, int]):
-        """
+        '''
         Find the closest WindowsColors to the given RGB color.

         >>> WindowsColors.from_rgb((0, 0, 0))
@@ -116,25 +216,40 @@ class WindowsColors(enum.Enum):

         >>> WindowsColors.from_rgb((128, 0, 128))
         <WindowsColors.MAGENTA: (128, 0, 128)>
-        """
-        pass
+        '''
+
+        def color_distance(rgb1, rgb2):
+            return sum((c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2))
+
+        return min(
+            WindowsColors,
+            key=lambda color: color_distance(color.value, rgb),
+        )


 class WindowsColor:
-    """
+    '''
     Windows compatible color class for when ANSI is not supported.
     Currently a no-op because it is not possible to buffer these colors.

     >>> WindowsColor(WindowsColors.RED)('test')
     'test'
-    """
-    __slots__ = 'color',
+    '''
+
+    __slots__ = ('color',)

     def __init__(self, color: Color):
         self.color = color

     def __call__(self, text):
         return text
+        ## In the future we might want to use this, but it requires direct
+        ## printing to stdout and all of our surrounding functions expect
+        ## buffered output so it's not feasible right now. Additionally,
+        ## recent Windows versions all support ANSI codes without issue so
+        ## there is little need.
+        # from progressbar.terminal.os_specific import windows
+        # windows.print_color(text, WindowsColors.from_rgb(self.color.rgb))


 class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])):
@@ -143,40 +258,98 @@ class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])):
     def __str__(self):
         return self.rgb

+    @property
+    def rgb(self):
+        return f'rgb({self.red}, {self.green}, {self.blue})'
+
+    @property
+    def hex(self):
+        return f'#{self.red:02x}{self.green:02x}{self.blue:02x}'
+
+    @property
+    def to_ansi_16(self):
+        # Using int instead of round because it maps slightly better
+        red = int(self.red / 255)
+        green = int(self.green / 255)
+        blue = int(self.blue / 255)
+        return (blue << 2) | (green << 1) | red
+
+    @property
+    def to_ansi_256(self):
+        red = round(self.red / 255 * 5)
+        green = round(self.green / 255 * 5)
+        blue = round(self.blue / 255 * 5)
+        return 16 + 36 * red + 6 * green + blue
+
     @property
     def to_windows(self):
-        """
+        '''
         Convert an RGB color (0-255 per channel) to the closest color in the
         Windows 16 color scheme.
-        """
-        pass
+        '''
+        return WindowsColors.from_rgb((self.red, self.green, self.blue))
+
+    def interpolate(self, end: RGB, step: float) -> RGB:
+        return RGB(
+            int(self.red + (end.red - self.red) * step),
+            int(self.green + (end.green - self.green) * step),
+            int(self.blue + (end.blue - self.blue) * step),
+        )


 class HSL(collections.namedtuple('HSL', ['hue', 'saturation', 'lightness'])):
-    """
+    '''
     Hue, Saturation, Lightness color.

     Hue is a value between 0 and 360, saturation and lightness are between 0(%)
     and 100(%).

-    """
+    '''
+
     __slots__ = ()

     @classmethod
-    def from_rgb(cls, rgb: RGB) ->HSL:
-        """
+    def from_rgb(cls, rgb: RGB) -> HSL:
+        '''
         Convert a 0-255 RGB color to a 0-255 HLS color.
-        """
-        pass
+        '''
+        hls = colorsys.rgb_to_hls(
+            rgb.red / 255,
+            rgb.green / 255,
+            rgb.blue / 255,
+        )
+        return cls(
+            round(hls[0] * 360),
+            round(hls[2] * 100),
+            round(hls[1] * 100),
+        )
+
+    def interpolate(self, end: HSL, step: float) -> HSL:
+        return HSL(
+            self.hue + (end.hue - self.hue) * step,
+            self.lightness + (end.lightness - self.lightness) * step,
+            self.saturation + (end.saturation - self.saturation) * step,
+        )


 class ColorBase(abc.ABC):
-    pass
-
-
-class Color(collections.namedtuple('Color', ['rgb', 'hls', 'name', 'xterm']
-    ), ColorBase):
-    """
+    def get_color(self, value: float) -> Color:
+        raise NotImplementedError()
+
+
+class Color(
+    collections.namedtuple(
+        'Color',
+        [
+            'rgb',
+            'hls',
+            'name',
+            'xterm',
+        ],
+    ),
+    ColorBase,
+):
+    '''
     Color base class.

     This class contains the colors in RGB (Red, Green, Blue), HSL (Hue,
@@ -186,12 +359,62 @@ class Color(collections.namedtuple('Color', ['rgb', 'hls', 'name', 'xterm']
     To make a custom color the only required arguments are the RGB values.
     The other values will be automatically interpolated from that if needed,
     but you can be more explicitly if you wish.
-    """
+    '''
+
     __slots__ = ()

-    def __call__(self, value: str) ->str:
+    def __call__(self, value: str) -> str:
         return self.fg(value)

+    @property
+    def fg(self):
+        if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS:
+            return WindowsColor(self)
+        else:
+            return SGRColor(self, 38, 39)
+
+    @property
+    def bg(self):
+        if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS:
+            return DummyColor()
+        else:
+            return SGRColor(self, 48, 49)
+
+    @property
+    def underline(self):
+        if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS:
+            return DummyColor()
+        else:
+            return SGRColor(self, 58, 59)
+
+    @property
+    def ansi(self) -> types.Optional[str]:
+        if (
+            env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR
+        ):  # pragma: no branch
+            return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}'
+
+        if self.xterm:  # pragma: no branch
+            color = self.xterm
+        elif (
+            env.COLOR_SUPPORT is env.ColorSupport.XTERM_256
+        ):  # pragma: no branch
+            color = self.rgb.to_ansi_256
+        elif env.COLOR_SUPPORT is env.ColorSupport.XTERM:  # pragma: no branch
+            color = self.rgb.to_ansi_16
+        else:  # pragma: no branch
+            return None
+
+        return f'5;{color}'
+
+    def interpolate(self, end: Color, step: float) -> Color:
+        return Color(
+            self.rgb.interpolate(end.rgb, step),
+            self.hls.interpolate(end.hls, step),
+            self.name if step < 0.5 else end.name,
+            self.xterm if step < 0.5 else end.xterm,
+        )
+
     def __str__(self):
         return self.name

@@ -203,51 +426,147 @@ class Color(collections.namedtuple('Color', ['rgb', 'hls', 'name', 'xterm']


 class Colors:
-    by_name: ClassVar[defaultdict[str, types.List[Color]]
-        ] = collections.defaultdict(list)
-    by_lowername: ClassVar[defaultdict[str, types.List[Color]]
-        ] = collections.defaultdict(list)
-    by_hex: ClassVar[defaultdict[str, types.List[Color]]
-        ] = collections.defaultdict(list)
-    by_rgb: ClassVar[defaultdict[RGB, types.List[Color]]
-        ] = collections.defaultdict(list)
-    by_hls: ClassVar[defaultdict[HSL, types.List[Color]]
-        ] = collections.defaultdict(list)
+    by_name: ClassVar[defaultdict[str, types.List[Color]]] = (
+        collections.defaultdict(list)
+    )
+    by_lowername: ClassVar[defaultdict[str, types.List[Color]]] = (
+        collections.defaultdict(list)
+    )
+    by_hex: ClassVar[defaultdict[str, types.List[Color]]] = (
+        collections.defaultdict(list)
+    )
+    by_rgb: ClassVar[defaultdict[RGB, types.List[Color]]] = (
+        collections.defaultdict(list)
+    )
+    by_hls: ClassVar[defaultdict[HSL, types.List[Color]]] = (
+        collections.defaultdict(list)
+    )
     by_xterm: ClassVar[dict[int, Color]] = dict()

+    @classmethod
+    def register(
+        cls,
+        rgb: RGB,
+        hls: types.Optional[HSL] = None,
+        name: types.Optional[str] = None,
+        xterm: types.Optional[int] = None,
+    ) -> Color:
+        color = Color(rgb, hls, name, xterm)
+
+        if name:
+            cls.by_name[name].append(color)
+            cls.by_lowername[name.lower()].append(color)

-class ColorGradient(ColorBase):
+        if hls is None:
+            hls = HSL.from_rgb(rgb)
+
+        cls.by_hex[rgb.hex].append(color)
+        cls.by_rgb[rgb].append(color)
+        cls.by_hls[hls].append(color)
+
+        if xterm is not None:
+            cls.by_xterm[xterm] = color
+
+        return color

+    @classmethod
+    def interpolate(cls, color_a: Color, color_b: Color, step: float) -> Color:
+        return color_a.interpolate(color_b, step)
+
+
+class ColorGradient(ColorBase):
     def __init__(self, *colors: Color, interpolate=Colors.interpolate):
         assert colors
         self.colors = colors
         self.interpolate = interpolate

-    def __call__(self, value: float) ->Color:
+    def __call__(self, value: float) -> Color:
         return self.get_color(value)

-    def get_color(self, value: float) ->Color:
-        """Map a value from 0 to 1 to a color."""
-        pass
+    def get_color(self, value: float) -> Color:
+        'Map a value from 0 to 1 to a color.'
+        if (
+            value == pbase.Undefined
+            or value == pbase.UnknownLength
+            or value <= 0
+        ):
+            return self.colors[0]
+        elif value >= 1:
+            return self.colors[-1]
+
+        max_color_idx = len(self.colors) - 1
+        if max_color_idx == 0:
+            return self.colors[0]
+        elif self.interpolate:
+            if max_color_idx > 1:
+                index = round(
+                    converters.remap(value, 0, 1, 0, max_color_idx - 1),
+                )
+            else:
+                index = 0
+
+            step = converters.remap(
+                value,
+                index / (max_color_idx),
+                (index + 1) / (max_color_idx),
+                0,
+                1,
+            )
+            color = self.interpolate(
+                self.colors[index],
+                self.colors[index + 1],
+                float(step),
+            )
+        else:
+            index = round(converters.remap(value, 0, 1, 0, max_color_idx))
+            color = self.colors[index]
+
+        return color


 OptionalColor = types.Union[Color, ColorGradient, None]


-def apply_colors(text: str, percentage: (float | None)=None, *, fg:
-    OptionalColor=None, bg: OptionalColor=None, fg_none: (Color | None)=
-    None, bg_none: (Color | None)=None, **kwargs: types.Any) ->str:
-    """Apply colors/gradients to a string depending on the given percentage.
+def get_color(value: float, color: OptionalColor) -> Color | None:
+    if isinstance(color, ColorGradient):
+        color = color(value)
+    return color
+
+
+def apply_colors(
+    text: str,
+    percentage: float | None = None,
+    *,
+    fg: OptionalColor = None,
+    bg: OptionalColor = None,
+    fg_none: Color | None = None,
+    bg_none: Color | None = None,
+    **kwargs: types.Any,
+) -> str:
+    '''Apply colors/gradients to a string depending on the given percentage.

     When percentage is `None`, the `fg_none` and `bg_none` colors will be used.
     Otherwise, the `fg` and `bg` colors will be used. If the colors are
     gradients, the color will be interpolated depending on the percentage.
-    """
-    pass
+    '''
+    if percentage is None:
+        if fg_none is not None:
+            text = fg_none.fg(text)
+        if bg_none is not None:
+            text = bg_none.bg(text)
+    elif fg is not None or bg is not None:
+        fg = get_color(percentage * 0.01, fg)
+        bg = get_color(percentage * 0.01, bg)

+        if fg is not None:  # pragma: no branch
+            text = fg.fg(text)
+        if bg is not None:  # pragma: no branch
+            text = bg.bg(text)
+
+    return text

-class DummyColor:

+class DummyColor:
     def __call__(self, text):
         return text

@@ -265,6 +584,14 @@ class SGR(CSI):
         self._start_code = start_code
         self._end_code = end_code

+    @property
+    def _start_template(self):
+        return super().__call__(self._start_code)
+
+    @property
+    def _end_template(self):
+        return super().__call__(self._end_code)
+
     def __call__(self, text, *args):
         return self._start_template + text + self._end_template

@@ -276,6 +603,10 @@ class SGRColor(SGR):
         self._color = color
         super().__init__(start_code, end_code)

+    @property
+    def _start_template(self):
+        return CSI.__call__(self, self._start_code, self._color.ansi)
+

 encircled = SGR(52, 54)
 framed = SGR(51, 54)
diff --git a/progressbar/terminal/colors.py b/progressbar/terminal/colors.py
index 95e6de2..53354ac 100644
--- a/progressbar/terminal/colors.py
+++ b/progressbar/terminal/colors.py
@@ -1,5 +1,8 @@
+# Based on: https://www.ditig.com/256-colors-cheat-sheet
 import os
+
 from progressbar.terminal.base import HSL, RGB, ColorGradient, Colors
+
 black = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Black', 0)
 maroon = Colors.register(RGB(128, 0, 0), HSL(0, 100, 25), 'Maroon', 1)
 green = Colors.register(RGB(0, 128, 0), HSL(120, 100, 25), 'Green', 2)
@@ -23,369 +26,989 @@ blue3 = Colors.register(RGB(0, 0, 175), HSL(240, 100, 34), 'Blue3', 19)
 blue3 = Colors.register(RGB(0, 0, 215), HSL(240, 100, 42), 'Blue3', 20)
 blue1 = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue1', 21)
 dark_green = Colors.register(RGB(0, 95, 0), HSL(120, 100, 18), 'DarkGreen', 22)
-deep_sky_blue4 = Colors.register(RGB(0, 95, 95), HSL(180, 100, 18),
-    'DeepSkyBlue4', 23)
-deep_sky_blue4 = Colors.register(RGB(0, 95, 135), HSL(97, 100, 26),
-    'DeepSkyBlue4', 24)
-deep_sky_blue4 = Colors.register(RGB(0, 95, 175), HSL(7, 100, 34),
-    'DeepSkyBlue4', 25)
-dodger_blue3 = Colors.register(RGB(0, 95, 215), HSL(13, 100, 42),
-    'DodgerBlue3', 26)
-dodger_blue2 = Colors.register(RGB(0, 95, 255), HSL(17, 100, 50),
-    'DodgerBlue2', 27)
+deep_sky_blue4 = Colors.register(
+    RGB(0, 95, 95),
+    HSL(180, 100, 18),
+    'DeepSkyBlue4',
+    23,
+)
+deep_sky_blue4 = Colors.register(
+    RGB(0, 95, 135),
+    HSL(97, 100, 26),
+    'DeepSkyBlue4',
+    24,
+)
+deep_sky_blue4 = Colors.register(
+    RGB(0, 95, 175),
+    HSL(7, 100, 34),
+    'DeepSkyBlue4',
+    25,
+)
+dodger_blue3 = Colors.register(
+    RGB(0, 95, 215),
+    HSL(13, 100, 42),
+    'DodgerBlue3',
+    26,
+)
+dodger_blue2 = Colors.register(
+    RGB(0, 95, 255),
+    HSL(17, 100, 50),
+    'DodgerBlue2',
+    27,
+)
 green4 = Colors.register(RGB(0, 135, 0), HSL(120, 100, 26), 'Green4', 28)
-spring_green4 = Colors.register(RGB(0, 135, 95), HSL(62, 100, 26),
-    'SpringGreen4', 29)
-turquoise4 = Colors.register(RGB(0, 135, 135), HSL(180, 100, 26),
-    'Turquoise4', 30)
-deep_sky_blue3 = Colors.register(RGB(0, 135, 175), HSL(93, 100, 34),
-    'DeepSkyBlue3', 31)
-deep_sky_blue3 = Colors.register(RGB(0, 135, 215), HSL(2, 100, 42),
-    'DeepSkyBlue3', 32)
-dodger_blue1 = Colors.register(RGB(0, 135, 255), HSL(8, 100, 50),
-    'DodgerBlue1', 33)
+spring_green4 = Colors.register(
+    RGB(0, 135, 95),
+    HSL(62, 100, 26),
+    'SpringGreen4',
+    29,
+)
+turquoise4 = Colors.register(
+    RGB(0, 135, 135),
+    HSL(180, 100, 26),
+    'Turquoise4',
+    30,
+)
+deep_sky_blue3 = Colors.register(
+    RGB(0, 135, 175),
+    HSL(93, 100, 34),
+    'DeepSkyBlue3',
+    31,
+)
+deep_sky_blue3 = Colors.register(
+    RGB(0, 135, 215),
+    HSL(2, 100, 42),
+    'DeepSkyBlue3',
+    32,
+)
+dodger_blue1 = Colors.register(
+    RGB(0, 135, 255),
+    HSL(8, 100, 50),
+    'DodgerBlue1',
+    33,
+)
 green3 = Colors.register(RGB(0, 175, 0), HSL(120, 100, 34), 'Green3', 34)
-spring_green3 = Colors.register(RGB(0, 175, 95), HSL(52, 100, 34),
-    'SpringGreen3', 35)
+spring_green3 = Colors.register(
+    RGB(0, 175, 95),
+    HSL(52, 100, 34),
+    'SpringGreen3',
+    35,
+)
 dark_cyan = Colors.register(RGB(0, 175, 135), HSL(66, 100, 34), 'DarkCyan', 36)
-light_sea_green = Colors.register(RGB(0, 175, 175), HSL(180, 100, 34),
-    'LightSeaGreen', 37)
-deep_sky_blue2 = Colors.register(RGB(0, 175, 215), HSL(91, 100, 42),
-    'DeepSkyBlue2', 38)
-deep_sky_blue1 = Colors.register(RGB(0, 175, 255), HSL(98, 100, 50),
-    'DeepSkyBlue1', 39)
+light_sea_green = Colors.register(
+    RGB(0, 175, 175),
+    HSL(180, 100, 34),
+    'LightSeaGreen',
+    37,
+)
+deep_sky_blue2 = Colors.register(
+    RGB(0, 175, 215),
+    HSL(91, 100, 42),
+    'DeepSkyBlue2',
+    38,
+)
+deep_sky_blue1 = Colors.register(
+    RGB(0, 175, 255),
+    HSL(98, 100, 50),
+    'DeepSkyBlue1',
+    39,
+)
 green3 = Colors.register(RGB(0, 215, 0), HSL(120, 100, 42), 'Green3', 40)
-spring_green3 = Colors.register(RGB(0, 215, 95), HSL(46, 100, 42),
-    'SpringGreen3', 41)
-spring_green2 = Colors.register(RGB(0, 215, 135), HSL(57, 100, 42),
-    'SpringGreen2', 42)
+spring_green3 = Colors.register(
+    RGB(0, 215, 95),
+    HSL(46, 100, 42),
+    'SpringGreen3',
+    41,
+)
+spring_green2 = Colors.register(
+    RGB(0, 215, 135),
+    HSL(57, 100, 42),
+    'SpringGreen2',
+    42,
+)
 cyan3 = Colors.register(RGB(0, 215, 175), HSL(68, 100, 42), 'Cyan3', 43)
-dark_turquoise = Colors.register(RGB(0, 215, 215), HSL(180, 100, 42),
-    'DarkTurquoise', 44)
-turquoise2 = Colors.register(RGB(0, 215, 255), HSL(89, 100, 50),
-    'Turquoise2', 45)
+dark_turquoise = Colors.register(
+    RGB(0, 215, 215),
+    HSL(180, 100, 42),
+    'DarkTurquoise',
+    44,
+)
+turquoise2 = Colors.register(
+    RGB(0, 215, 255),
+    HSL(89, 100, 50),
+    'Turquoise2',
+    45,
+)
 green1 = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Green1', 46)
-spring_green2 = Colors.register(RGB(0, 255, 95), HSL(42, 100, 50),
-    'SpringGreen2', 47)
-spring_green1 = Colors.register(RGB(0, 255, 135), HSL(51, 100, 50),
-    'SpringGreen1', 48)
-medium_spring_green = Colors.register(RGB(0, 255, 175), HSL(61, 100, 50),
-    'MediumSpringGreen', 49)
+spring_green2 = Colors.register(
+    RGB(0, 255, 95),
+    HSL(42, 100, 50),
+    'SpringGreen2',
+    47,
+)
+spring_green1 = Colors.register(
+    RGB(0, 255, 135),
+    HSL(51, 100, 50),
+    'SpringGreen1',
+    48,
+)
+medium_spring_green = Colors.register(
+    RGB(0, 255, 175),
+    HSL(61, 100, 50),
+    'MediumSpringGreen',
+    49,
+)
 cyan2 = Colors.register(RGB(0, 255, 215), HSL(70, 100, 50), 'Cyan2', 50)
 cyan1 = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Cyan1', 51)
 dark_red = Colors.register(RGB(95, 0, 0), HSL(0, 100, 18), 'DarkRed', 52)
-deep_pink4 = Colors.register(RGB(95, 0, 95), HSL(300, 100, 18), 'DeepPink4', 53
-    )
+deep_pink4 = Colors.register(
+    RGB(95, 0, 95),
+    HSL(300, 100, 18),
+    'DeepPink4',
+    53,
+)
 purple4 = Colors.register(RGB(95, 0, 135), HSL(82, 100, 26), 'Purple4', 54)
 purple4 = Colors.register(RGB(95, 0, 175), HSL(72, 100, 34), 'Purple4', 55)
 purple3 = Colors.register(RGB(95, 0, 215), HSL(66, 100, 42), 'Purple3', 56)
-blue_violet = Colors.register(RGB(95, 0, 255), HSL(62, 100, 50),
-    'BlueViolet', 57)
+blue_violet = Colors.register(
+    RGB(95, 0, 255),
+    HSL(62, 100, 50),
+    'BlueViolet',
+    57,
+)
 orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 18), 'Orange4', 58)
 grey37 = Colors.register(RGB(95, 95, 95), HSL(0, 0, 37), 'Grey37', 59)
-medium_purple4 = Colors.register(RGB(95, 95, 135), HSL(240, 17, 45),
-    'MediumPurple4', 60)
-slate_blue3 = Colors.register(RGB(95, 95, 175), HSL(240, 33, 52),
-    'SlateBlue3', 61)
-slate_blue3 = Colors.register(RGB(95, 95, 215), HSL(240, 60, 60),
-    'SlateBlue3', 62)
-royal_blue1 = Colors.register(RGB(95, 95, 255), HSL(240, 100, 68),
-    'RoyalBlue1', 63)
-chartreuse4 = Colors.register(RGB(95, 135, 0), HSL(7, 100, 26),
-    'Chartreuse4', 64)
-dark_sea_green4 = Colors.register(RGB(95, 135, 95), HSL(120, 17, 45),
-    'DarkSeaGreen4', 65)
-pale_turquoise4 = Colors.register(RGB(95, 135, 135), HSL(180, 17, 45),
-    'PaleTurquoise4', 66)
-steel_blue = Colors.register(RGB(95, 135, 175), HSL(210, 33, 52),
-    'SteelBlue', 67)
-steel_blue3 = Colors.register(RGB(95, 135, 215), HSL(220, 60, 60),
-    'SteelBlue3', 68)
-cornflower_blue = Colors.register(RGB(95, 135, 255), HSL(225, 100, 68),
-    'CornflowerBlue', 69)
-chartreuse3 = Colors.register(RGB(95, 175, 0), HSL(7, 100, 34),
-    'Chartreuse3', 70)
-dark_sea_green4 = Colors.register(RGB(95, 175, 95), HSL(120, 33, 52),
-    'DarkSeaGreen4', 71)
-cadet_blue = Colors.register(RGB(95, 175, 135), HSL(150, 33, 52),
-    'CadetBlue', 72)
-cadet_blue = Colors.register(RGB(95, 175, 175), HSL(180, 33, 52),
-    'CadetBlue', 73)
-sky_blue3 = Colors.register(RGB(95, 175, 215), HSL(200, 60, 60), 'SkyBlue3', 74
-    )
-steel_blue1 = Colors.register(RGB(95, 175, 255), HSL(210, 100, 68),
-    'SteelBlue1', 75)
-chartreuse3 = Colors.register(RGB(95, 215, 0), HSL(3, 100, 42),
-    'Chartreuse3', 76)
-pale_green3 = Colors.register(RGB(95, 215, 95), HSL(120, 60, 60),
-    'PaleGreen3', 77)
-sea_green3 = Colors.register(RGB(95, 215, 135), HSL(140, 60, 60),
-    'SeaGreen3', 78)
-aquamarine3 = Colors.register(RGB(95, 215, 175), HSL(160, 60, 60),
-    'Aquamarine3', 79)
-medium_turquoise = Colors.register(RGB(95, 215, 215), HSL(180, 60, 60),
-    'MediumTurquoise', 80)
-steel_blue1 = Colors.register(RGB(95, 215, 255), HSL(195, 100, 68),
-    'SteelBlue1', 81)
-chartreuse2 = Colors.register(RGB(95, 255, 0), HSL(7, 100, 50),
-    'Chartreuse2', 82)
-sea_green2 = Colors.register(RGB(95, 255, 95), HSL(120, 100, 68),
-    'SeaGreen2', 83)
-sea_green1 = Colors.register(RGB(95, 255, 135), HSL(135, 100, 68),
-    'SeaGreen1', 84)
-sea_green1 = Colors.register(RGB(95, 255, 175), HSL(150, 100, 68),
-    'SeaGreen1', 85)
-aquamarine1 = Colors.register(RGB(95, 255, 215), HSL(165, 100, 68),
-    'Aquamarine1', 86)
-dark_slate_gray2 = Colors.register(RGB(95, 255, 255), HSL(180, 100, 68),
-    'DarkSlateGray2', 87)
+medium_purple4 = Colors.register(
+    RGB(95, 95, 135),
+    HSL(240, 17, 45),
+    'MediumPurple4',
+    60,
+)
+slate_blue3 = Colors.register(
+    RGB(95, 95, 175),
+    HSL(240, 33, 52),
+    'SlateBlue3',
+    61,
+)
+slate_blue3 = Colors.register(
+    RGB(95, 95, 215),
+    HSL(240, 60, 60),
+    'SlateBlue3',
+    62,
+)
+royal_blue1 = Colors.register(
+    RGB(95, 95, 255),
+    HSL(240, 100, 68),
+    'RoyalBlue1',
+    63,
+)
+chartreuse4 = Colors.register(
+    RGB(95, 135, 0),
+    HSL(7, 100, 26),
+    'Chartreuse4',
+    64,
+)
+dark_sea_green4 = Colors.register(
+    RGB(95, 135, 95),
+    HSL(120, 17, 45),
+    'DarkSeaGreen4',
+    65,
+)
+pale_turquoise4 = Colors.register(
+    RGB(95, 135, 135),
+    HSL(180, 17, 45),
+    'PaleTurquoise4',
+    66,
+)
+steel_blue = Colors.register(
+    RGB(95, 135, 175),
+    HSL(210, 33, 52),
+    'SteelBlue',
+    67,
+)
+steel_blue3 = Colors.register(
+    RGB(95, 135, 215),
+    HSL(220, 60, 60),
+    'SteelBlue3',
+    68,
+)
+cornflower_blue = Colors.register(
+    RGB(95, 135, 255),
+    HSL(225, 100, 68),
+    'CornflowerBlue',
+    69,
+)
+chartreuse3 = Colors.register(
+    RGB(95, 175, 0),
+    HSL(7, 100, 34),
+    'Chartreuse3',
+    70,
+)
+dark_sea_green4 = Colors.register(
+    RGB(95, 175, 95),
+    HSL(120, 33, 52),
+    'DarkSeaGreen4',
+    71,
+)
+cadet_blue = Colors.register(
+    RGB(95, 175, 135),
+    HSL(150, 33, 52),
+    'CadetBlue',
+    72,
+)
+cadet_blue = Colors.register(
+    RGB(95, 175, 175),
+    HSL(180, 33, 52),
+    'CadetBlue',
+    73,
+)
+sky_blue3 = Colors.register(
+    RGB(95, 175, 215),
+    HSL(200, 60, 60),
+    'SkyBlue3',
+    74,
+)
+steel_blue1 = Colors.register(
+    RGB(95, 175, 255),
+    HSL(210, 100, 68),
+    'SteelBlue1',
+    75,
+)
+chartreuse3 = Colors.register(
+    RGB(95, 215, 0),
+    HSL(3, 100, 42),
+    'Chartreuse3',
+    76,
+)
+pale_green3 = Colors.register(
+    RGB(95, 215, 95),
+    HSL(120, 60, 60),
+    'PaleGreen3',
+    77,
+)
+sea_green3 = Colors.register(
+    RGB(95, 215, 135),
+    HSL(140, 60, 60),
+    'SeaGreen3',
+    78,
+)
+aquamarine3 = Colors.register(
+    RGB(95, 215, 175),
+    HSL(160, 60, 60),
+    'Aquamarine3',
+    79,
+)
+medium_turquoise = Colors.register(
+    RGB(95, 215, 215),
+    HSL(180, 60, 60),
+    'MediumTurquoise',
+    80,
+)
+steel_blue1 = Colors.register(
+    RGB(95, 215, 255),
+    HSL(195, 100, 68),
+    'SteelBlue1',
+    81,
+)
+chartreuse2 = Colors.register(
+    RGB(95, 255, 0),
+    HSL(7, 100, 50),
+    'Chartreuse2',
+    82,
+)
+sea_green2 = Colors.register(
+    RGB(95, 255, 95),
+    HSL(120, 100, 68),
+    'SeaGreen2',
+    83,
+)
+sea_green1 = Colors.register(
+    RGB(95, 255, 135),
+    HSL(135, 100, 68),
+    'SeaGreen1',
+    84,
+)
+sea_green1 = Colors.register(
+    RGB(95, 255, 175),
+    HSL(150, 100, 68),
+    'SeaGreen1',
+    85,
+)
+aquamarine1 = Colors.register(
+    RGB(95, 255, 215),
+    HSL(165, 100, 68),
+    'Aquamarine1',
+    86,
+)
+dark_slate_gray2 = Colors.register(
+    RGB(95, 255, 255),
+    HSL(180, 100, 68),
+    'DarkSlateGray2',
+    87,
+)
 dark_red = Colors.register(RGB(135, 0, 0), HSL(0, 100, 26), 'DarkRed', 88)
-deep_pink4 = Colors.register(RGB(135, 0, 95), HSL(17, 100, 26), 'DeepPink4', 89
-    )
-dark_magenta = Colors.register(RGB(135, 0, 135), HSL(300, 100, 26),
-    'DarkMagenta', 90)
-dark_magenta = Colors.register(RGB(135, 0, 175), HSL(86, 100, 34),
-    'DarkMagenta', 91)
-dark_violet = Colors.register(RGB(135, 0, 215), HSL(77, 100, 42),
-    'DarkViolet', 92)
+deep_pink4 = Colors.register(
+    RGB(135, 0, 95),
+    HSL(17, 100, 26),
+    'DeepPink4',
+    89,
+)
+dark_magenta = Colors.register(
+    RGB(135, 0, 135),
+    HSL(300, 100, 26),
+    'DarkMagenta',
+    90,
+)
+dark_magenta = Colors.register(
+    RGB(135, 0, 175),
+    HSL(86, 100, 34),
+    'DarkMagenta',
+    91,
+)
+dark_violet = Colors.register(
+    RGB(135, 0, 215),
+    HSL(77, 100, 42),
+    'DarkViolet',
+    92,
+)
 purple = Colors.register(RGB(135, 0, 255), HSL(71, 100, 50), 'Purple', 93)
 orange4 = Colors.register(RGB(135, 95, 0), HSL(2, 100, 26), 'Orange4', 94)
-light_pink4 = Colors.register(RGB(135, 95, 95), HSL(0, 17, 45),
-    'LightPink4', 95)
+light_pink4 = Colors.register(
+    RGB(135, 95, 95),
+    HSL(0, 17, 45),
+    'LightPink4',
+    95,
+)
 plum4 = Colors.register(RGB(135, 95, 135), HSL(300, 17, 45), 'Plum4', 96)
-medium_purple3 = Colors.register(RGB(135, 95, 175), HSL(270, 33, 52),
-    'MediumPurple3', 97)
-medium_purple3 = Colors.register(RGB(135, 95, 215), HSL(260, 60, 60),
-    'MediumPurple3', 98)
-slate_blue1 = Colors.register(RGB(135, 95, 255), HSL(255, 100, 68),
-    'SlateBlue1', 99)
+medium_purple3 = Colors.register(
+    RGB(135, 95, 175),
+    HSL(270, 33, 52),
+    'MediumPurple3',
+    97,
+)
+medium_purple3 = Colors.register(
+    RGB(135, 95, 215),
+    HSL(260, 60, 60),
+    'MediumPurple3',
+    98,
+)
+slate_blue1 = Colors.register(
+    RGB(135, 95, 255),
+    HSL(255, 100, 68),
+    'SlateBlue1',
+    99,
+)
 yellow4 = Colors.register(RGB(135, 135, 0), HSL(60, 100, 26), 'Yellow4', 100)
 wheat4 = Colors.register(RGB(135, 135, 95), HSL(60, 17, 45), 'Wheat4', 101)
 grey53 = Colors.register(RGB(135, 135, 135), HSL(0, 0, 52), 'Grey53', 102)
-light_slate_grey = Colors.register(RGB(135, 135, 175), HSL(240, 20, 60),
-    'LightSlateGrey', 103)
-medium_purple = Colors.register(RGB(135, 135, 215), HSL(240, 50, 68),
-    'MediumPurple', 104)
-light_slate_blue = Colors.register(RGB(135, 135, 255), HSL(240, 100, 76),
-    'LightSlateBlue', 105)
+light_slate_grey = Colors.register(
+    RGB(135, 135, 175),
+    HSL(240, 20, 60),
+    'LightSlateGrey',
+    103,
+)
+medium_purple = Colors.register(
+    RGB(135, 135, 215),
+    HSL(240, 50, 68),
+    'MediumPurple',
+    104,
+)
+light_slate_blue = Colors.register(
+    RGB(135, 135, 255),
+    HSL(240, 100, 76),
+    'LightSlateBlue',
+    105,
+)
 yellow4 = Colors.register(RGB(135, 175, 0), HSL(3, 100, 34), 'Yellow4', 106)
-dark_olive_green3 = Colors.register(RGB(135, 175, 95), HSL(90, 33, 52),
-    'DarkOliveGreen3', 107)
-dark_sea_green = Colors.register(RGB(135, 175, 135), HSL(120, 20, 60),
-    'DarkSeaGreen', 108)
-light_sky_blue3 = Colors.register(RGB(135, 175, 175), HSL(180, 20, 60),
-    'LightSkyBlue3', 109)
-light_sky_blue3 = Colors.register(RGB(135, 175, 215), HSL(210, 50, 68),
-    'LightSkyBlue3', 110)
-sky_blue2 = Colors.register(RGB(135, 175, 255), HSL(220, 100, 76),
-    'SkyBlue2', 111)
-chartreuse2 = Colors.register(RGB(135, 215, 0), HSL(2, 100, 42),
-    'Chartreuse2', 112)
-dark_olive_green3 = Colors.register(RGB(135, 215, 95), HSL(100, 60, 60),
-    'DarkOliveGreen3', 113)
-pale_green3 = Colors.register(RGB(135, 215, 135), HSL(120, 50, 68),
-    'PaleGreen3', 114)
-dark_sea_green3 = Colors.register(RGB(135, 215, 175), HSL(150, 50, 68),
-    'DarkSeaGreen3', 115)
-dark_slate_gray3 = Colors.register(RGB(135, 215, 215), HSL(180, 50, 68),
-    'DarkSlateGray3', 116)
-sky_blue1 = Colors.register(RGB(135, 215, 255), HSL(200, 100, 76),
-    'SkyBlue1', 117)
-chartreuse1 = Colors.register(RGB(135, 255, 0), HSL(8, 100, 50),
-    'Chartreuse1', 118)
-light_green = Colors.register(RGB(135, 255, 95), HSL(105, 100, 68),
-    'LightGreen', 119)
-light_green = Colors.register(RGB(135, 255, 135), HSL(120, 100, 76),
-    'LightGreen', 120)
-pale_green1 = Colors.register(RGB(135, 255, 175), HSL(140, 100, 76),
-    'PaleGreen1', 121)
-aquamarine1 = Colors.register(RGB(135, 255, 215), HSL(160, 100, 76),
-    'Aquamarine1', 122)
-dark_slate_gray1 = Colors.register(RGB(135, 255, 255), HSL(180, 100, 76),
-    'DarkSlateGray1', 123)
+dark_olive_green3 = Colors.register(
+    RGB(135, 175, 95),
+    HSL(90, 33, 52),
+    'DarkOliveGreen3',
+    107,
+)
+dark_sea_green = Colors.register(
+    RGB(135, 175, 135),
+    HSL(120, 20, 60),
+    'DarkSeaGreen',
+    108,
+)
+light_sky_blue3 = Colors.register(
+    RGB(135, 175, 175),
+    HSL(180, 20, 60),
+    'LightSkyBlue3',
+    109,
+)
+light_sky_blue3 = Colors.register(
+    RGB(135, 175, 215),
+    HSL(210, 50, 68),
+    'LightSkyBlue3',
+    110,
+)
+sky_blue2 = Colors.register(
+    RGB(135, 175, 255),
+    HSL(220, 100, 76),
+    'SkyBlue2',
+    111,
+)
+chartreuse2 = Colors.register(
+    RGB(135, 215, 0),
+    HSL(2, 100, 42),
+    'Chartreuse2',
+    112,
+)
+dark_olive_green3 = Colors.register(
+    RGB(135, 215, 95),
+    HSL(100, 60, 60),
+    'DarkOliveGreen3',
+    113,
+)
+pale_green3 = Colors.register(
+    RGB(135, 215, 135),
+    HSL(120, 50, 68),
+    'PaleGreen3',
+    114,
+)
+dark_sea_green3 = Colors.register(
+    RGB(135, 215, 175),
+    HSL(150, 50, 68),
+    'DarkSeaGreen3',
+    115,
+)
+dark_slate_gray3 = Colors.register(
+    RGB(135, 215, 215),
+    HSL(180, 50, 68),
+    'DarkSlateGray3',
+    116,
+)
+sky_blue1 = Colors.register(
+    RGB(135, 215, 255),
+    HSL(200, 100, 76),
+    'SkyBlue1',
+    117,
+)
+chartreuse1 = Colors.register(
+    RGB(135, 255, 0),
+    HSL(8, 100, 50),
+    'Chartreuse1',
+    118,
+)
+light_green = Colors.register(
+    RGB(135, 255, 95),
+    HSL(105, 100, 68),
+    'LightGreen',
+    119,
+)
+light_green = Colors.register(
+    RGB(135, 255, 135),
+    HSL(120, 100, 76),
+    'LightGreen',
+    120,
+)
+pale_green1 = Colors.register(
+    RGB(135, 255, 175),
+    HSL(140, 100, 76),
+    'PaleGreen1',
+    121,
+)
+aquamarine1 = Colors.register(
+    RGB(135, 255, 215),
+    HSL(160, 100, 76),
+    'Aquamarine1',
+    122,
+)
+dark_slate_gray1 = Colors.register(
+    RGB(135, 255, 255),
+    HSL(180, 100, 76),
+    'DarkSlateGray1',
+    123,
+)
 red3 = Colors.register(RGB(175, 0, 0), HSL(0, 100, 34), 'Red3', 124)
-deep_pink4 = Colors.register(RGB(175, 0, 95), HSL(27, 100, 34), 'DeepPink4',
-    125)
-medium_violet_red = Colors.register(RGB(175, 0, 135), HSL(13, 100, 34),
-    'MediumVioletRed', 126)
-magenta3 = Colors.register(RGB(175, 0, 175), HSL(300, 100, 34), 'Magenta3', 127
-    )
-dark_violet = Colors.register(RGB(175, 0, 215), HSL(88, 100, 42),
-    'DarkViolet', 128)
+deep_pink4 = Colors.register(
+    RGB(175, 0, 95),
+    HSL(27, 100, 34),
+    'DeepPink4',
+    125,
+)
+medium_violet_red = Colors.register(
+    RGB(175, 0, 135),
+    HSL(13, 100, 34),
+    'MediumVioletRed',
+    126,
+)
+magenta3 = Colors.register(
+    RGB(175, 0, 175),
+    HSL(300, 100, 34),
+    'Magenta3',
+    127,
+)
+dark_violet = Colors.register(
+    RGB(175, 0, 215),
+    HSL(88, 100, 42),
+    'DarkViolet',
+    128,
+)
 purple = Colors.register(RGB(175, 0, 255), HSL(81, 100, 50), 'Purple', 129)
-dark_orange3 = Colors.register(RGB(175, 95, 0), HSL(2, 100, 34),
-    'DarkOrange3', 130)
-indian_red = Colors.register(RGB(175, 95, 95), HSL(0, 33, 52), 'IndianRed', 131
-    )
-hot_pink3 = Colors.register(RGB(175, 95, 135), HSL(330, 33, 52), 'HotPink3',
-    132)
-medium_orchid3 = Colors.register(RGB(175, 95, 175), HSL(300, 33, 52),
-    'MediumOrchid3', 133)
-medium_orchid = Colors.register(RGB(175, 95, 215), HSL(280, 60, 60),
-    'MediumOrchid', 134)
-medium_purple2 = Colors.register(RGB(175, 95, 255), HSL(270, 100, 68),
-    'MediumPurple2', 135)
-dark_goldenrod = Colors.register(RGB(175, 135, 0), HSL(6, 100, 34),
-    'DarkGoldenrod', 136)
-light_salmon3 = Colors.register(RGB(175, 135, 95), HSL(30, 33, 52),
-    'LightSalmon3', 137)
-rosy_brown = Colors.register(RGB(175, 135, 135), HSL(0, 20, 60),
-    'RosyBrown', 138)
+dark_orange3 = Colors.register(
+    RGB(175, 95, 0),
+    HSL(2, 100, 34),
+    'DarkOrange3',
+    130,
+)
+indian_red = Colors.register(
+    RGB(175, 95, 95),
+    HSL(0, 33, 52),
+    'IndianRed',
+    131,
+)
+hot_pink3 = Colors.register(
+    RGB(175, 95, 135),
+    HSL(330, 33, 52),
+    'HotPink3',
+    132,
+)
+medium_orchid3 = Colors.register(
+    RGB(175, 95, 175),
+    HSL(300, 33, 52),
+    'MediumOrchid3',
+    133,
+)
+medium_orchid = Colors.register(
+    RGB(175, 95, 215),
+    HSL(280, 60, 60),
+    'MediumOrchid',
+    134,
+)
+medium_purple2 = Colors.register(
+    RGB(175, 95, 255),
+    HSL(270, 100, 68),
+    'MediumPurple2',
+    135,
+)
+dark_goldenrod = Colors.register(
+    RGB(175, 135, 0),
+    HSL(6, 100, 34),
+    'DarkGoldenrod',
+    136,
+)
+light_salmon3 = Colors.register(
+    RGB(175, 135, 95),
+    HSL(30, 33, 52),
+    'LightSalmon3',
+    137,
+)
+rosy_brown = Colors.register(
+    RGB(175, 135, 135),
+    HSL(0, 20, 60),
+    'RosyBrown',
+    138,
+)
 grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 60), 'Grey63', 139)
-medium_purple2 = Colors.register(RGB(175, 135, 215), HSL(270, 50, 68),
-    'MediumPurple2', 140)
-medium_purple1 = Colors.register(RGB(175, 135, 255), HSL(260, 100, 76),
-    'MediumPurple1', 141)
+medium_purple2 = Colors.register(
+    RGB(175, 135, 215),
+    HSL(270, 50, 68),
+    'MediumPurple2',
+    140,
+)
+medium_purple1 = Colors.register(
+    RGB(175, 135, 255),
+    HSL(260, 100, 76),
+    'MediumPurple1',
+    141,
+)
 gold3 = Colors.register(RGB(175, 175, 0), HSL(60, 100, 34), 'Gold3', 142)
-dark_khaki = Colors.register(RGB(175, 175, 95), HSL(60, 33, 52),
-    'DarkKhaki', 143)
-navajo_white3 = Colors.register(RGB(175, 175, 135), HSL(60, 20, 60),
-    'NavajoWhite3', 144)
+dark_khaki = Colors.register(
+    RGB(175, 175, 95),
+    HSL(60, 33, 52),
+    'DarkKhaki',
+    143,
+)
+navajo_white3 = Colors.register(
+    RGB(175, 175, 135),
+    HSL(60, 20, 60),
+    'NavajoWhite3',
+    144,
+)
 grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 68), 'Grey69', 145)
-light_steel_blue3 = Colors.register(RGB(175, 175, 215), HSL(240, 33, 76),
-    'LightSteelBlue3', 146)
-light_steel_blue = Colors.register(RGB(175, 175, 255), HSL(240, 100, 84),
-    'LightSteelBlue', 147)
+light_steel_blue3 = Colors.register(
+    RGB(175, 175, 215),
+    HSL(240, 33, 76),
+    'LightSteelBlue3',
+    146,
+)
+light_steel_blue = Colors.register(
+    RGB(175, 175, 255),
+    HSL(240, 100, 84),
+    'LightSteelBlue',
+    147,
+)
 yellow3 = Colors.register(RGB(175, 215, 0), HSL(1, 100, 42), 'Yellow3', 148)
-dark_olive_green3 = Colors.register(RGB(175, 215, 95), HSL(80, 60, 60),
-    'DarkOliveGreen3', 149)
-dark_sea_green3 = Colors.register(RGB(175, 215, 135), HSL(90, 50, 68),
-    'DarkSeaGreen3', 150)
-dark_sea_green2 = Colors.register(RGB(175, 215, 175), HSL(120, 33, 76),
-    'DarkSeaGreen2', 151)
-light_cyan3 = Colors.register(RGB(175, 215, 215), HSL(180, 33, 76),
-    'LightCyan3', 152)
-light_sky_blue1 = Colors.register(RGB(175, 215, 255), HSL(210, 100, 84),
-    'LightSkyBlue1', 153)
-green_yellow = Colors.register(RGB(175, 255, 0), HSL(8, 100, 50),
-    'GreenYellow', 154)
-dark_olive_green2 = Colors.register(RGB(175, 255, 95), HSL(90, 100, 68),
-    'DarkOliveGreen2', 155)
-pale_green1 = Colors.register(RGB(175, 255, 135), HSL(100, 100, 76),
-    'PaleGreen1', 156)
-dark_sea_green2 = Colors.register(RGB(175, 255, 175), HSL(120, 100, 84),
-    'DarkSeaGreen2', 157)
-dark_sea_green1 = Colors.register(RGB(175, 255, 215), HSL(150, 100, 84),
-    'DarkSeaGreen1', 158)
-pale_turquoise1 = Colors.register(RGB(175, 255, 255), HSL(180, 100, 84),
-    'PaleTurquoise1', 159)
+dark_olive_green3 = Colors.register(
+    RGB(175, 215, 95),
+    HSL(80, 60, 60),
+    'DarkOliveGreen3',
+    149,
+)
+dark_sea_green3 = Colors.register(
+    RGB(175, 215, 135),
+    HSL(90, 50, 68),
+    'DarkSeaGreen3',
+    150,
+)
+dark_sea_green2 = Colors.register(
+    RGB(175, 215, 175),
+    HSL(120, 33, 76),
+    'DarkSeaGreen2',
+    151,
+)
+light_cyan3 = Colors.register(
+    RGB(175, 215, 215),
+    HSL(180, 33, 76),
+    'LightCyan3',
+    152,
+)
+light_sky_blue1 = Colors.register(
+    RGB(175, 215, 255),
+    HSL(210, 100, 84),
+    'LightSkyBlue1',
+    153,
+)
+green_yellow = Colors.register(
+    RGB(175, 255, 0),
+    HSL(8, 100, 50),
+    'GreenYellow',
+    154,
+)
+dark_olive_green2 = Colors.register(
+    RGB(175, 255, 95),
+    HSL(90, 100, 68),
+    'DarkOliveGreen2',
+    155,
+)
+pale_green1 = Colors.register(
+    RGB(175, 255, 135),
+    HSL(100, 100, 76),
+    'PaleGreen1',
+    156,
+)
+dark_sea_green2 = Colors.register(
+    RGB(175, 255, 175),
+    HSL(120, 100, 84),
+    'DarkSeaGreen2',
+    157,
+)
+dark_sea_green1 = Colors.register(
+    RGB(175, 255, 215),
+    HSL(150, 100, 84),
+    'DarkSeaGreen1',
+    158,
+)
+pale_turquoise1 = Colors.register(
+    RGB(175, 255, 255),
+    HSL(180, 100, 84),
+    'PaleTurquoise1',
+    159,
+)
 red3 = Colors.register(RGB(215, 0, 0), HSL(0, 100, 42), 'Red3', 160)
-deep_pink3 = Colors.register(RGB(215, 0, 95), HSL(33, 100, 42), 'DeepPink3',
-    161)
-deep_pink3 = Colors.register(RGB(215, 0, 135), HSL(22, 100, 42),
-    'DeepPink3', 162)
+deep_pink3 = Colors.register(
+    RGB(215, 0, 95),
+    HSL(33, 100, 42),
+    'DeepPink3',
+    161,
+)
+deep_pink3 = Colors.register(
+    RGB(215, 0, 135),
+    HSL(22, 100, 42),
+    'DeepPink3',
+    162,
+)
 magenta3 = Colors.register(RGB(215, 0, 175), HSL(11, 100, 42), 'Magenta3', 163)
-magenta3 = Colors.register(RGB(215, 0, 215), HSL(300, 100, 42), 'Magenta3', 164
-    )
+magenta3 = Colors.register(
+    RGB(215, 0, 215),
+    HSL(300, 100, 42),
+    'Magenta3',
+    164,
+)
 magenta2 = Colors.register(RGB(215, 0, 255), HSL(90, 100, 50), 'Magenta2', 165)
-dark_orange3 = Colors.register(RGB(215, 95, 0), HSL(6, 100, 42),
-    'DarkOrange3', 166)
-indian_red = Colors.register(RGB(215, 95, 95), HSL(0, 60, 60), 'IndianRed', 167
-    )
-hot_pink3 = Colors.register(RGB(215, 95, 135), HSL(340, 60, 60), 'HotPink3',
-    168)
-hot_pink2 = Colors.register(RGB(215, 95, 175), HSL(320, 60, 60), 'HotPink2',
-    169)
+dark_orange3 = Colors.register(
+    RGB(215, 95, 0),
+    HSL(6, 100, 42),
+    'DarkOrange3',
+    166,
+)
+indian_red = Colors.register(
+    RGB(215, 95, 95),
+    HSL(0, 60, 60),
+    'IndianRed',
+    167,
+)
+hot_pink3 = Colors.register(
+    RGB(215, 95, 135),
+    HSL(340, 60, 60),
+    'HotPink3',
+    168,
+)
+hot_pink2 = Colors.register(
+    RGB(215, 95, 175),
+    HSL(320, 60, 60),
+    'HotPink2',
+    169,
+)
 orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 60), 'Orchid', 170)
-medium_orchid1 = Colors.register(RGB(215, 95, 255), HSL(285, 100, 68),
-    'MediumOrchid1', 171)
+medium_orchid1 = Colors.register(
+    RGB(215, 95, 255),
+    HSL(285, 100, 68),
+    'MediumOrchid1',
+    171,
+)
 orange3 = Colors.register(RGB(215, 135, 0), HSL(7, 100, 42), 'Orange3', 172)
-light_salmon3 = Colors.register(RGB(215, 135, 95), HSL(20, 60, 60),
-    'LightSalmon3', 173)
-light_pink3 = Colors.register(RGB(215, 135, 135), HSL(0, 50, 68),
-    'LightPink3', 174)
+light_salmon3 = Colors.register(
+    RGB(215, 135, 95),
+    HSL(20, 60, 60),
+    'LightSalmon3',
+    173,
+)
+light_pink3 = Colors.register(
+    RGB(215, 135, 135),
+    HSL(0, 50, 68),
+    'LightPink3',
+    174,
+)
 pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 68), 'Pink3', 175)
 plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 68), 'Plum3', 176)
 violet = Colors.register(RGB(215, 135, 255), HSL(280, 100, 76), 'Violet', 177)
 gold3 = Colors.register(RGB(215, 175, 0), HSL(8, 100, 42), 'Gold3', 178)
-light_goldenrod3 = Colors.register(RGB(215, 175, 95), HSL(40, 60, 60),
-    'LightGoldenrod3', 179)
+light_goldenrod3 = Colors.register(
+    RGB(215, 175, 95),
+    HSL(40, 60, 60),
+    'LightGoldenrod3',
+    179,
+)
 tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 68), 'Tan', 180)
-misty_rose3 = Colors.register(RGB(215, 175, 175), HSL(0, 33, 76),
-    'MistyRose3', 181)
-thistle3 = Colors.register(RGB(215, 175, 215), HSL(300, 33, 76), 'Thistle3',
-    182)
+misty_rose3 = Colors.register(
+    RGB(215, 175, 175),
+    HSL(0, 33, 76),
+    'MistyRose3',
+    181,
+)
+thistle3 = Colors.register(
+    RGB(215, 175, 215),
+    HSL(300, 33, 76),
+    'Thistle3',
+    182,
+)
 plum2 = Colors.register(RGB(215, 175, 255), HSL(270, 100, 84), 'Plum2', 183)
 yellow3 = Colors.register(RGB(215, 215, 0), HSL(60, 100, 42), 'Yellow3', 184)
 khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 60), 'Khaki3', 185)
-light_goldenrod2 = Colors.register(RGB(215, 215, 135), HSL(60, 50, 68),
-    'LightGoldenrod2', 186)
-light_yellow3 = Colors.register(RGB(215, 215, 175), HSL(60, 33, 76),
-    'LightYellow3', 187)
+light_goldenrod2 = Colors.register(
+    RGB(215, 215, 135),
+    HSL(60, 50, 68),
+    'LightGoldenrod2',
+    186,
+)
+light_yellow3 = Colors.register(
+    RGB(215, 215, 175),
+    HSL(60, 33, 76),
+    'LightYellow3',
+    187,
+)
 grey84 = Colors.register(RGB(215, 215, 215), HSL(0, 0, 84), 'Grey84', 188)
-light_steel_blue1 = Colors.register(RGB(215, 215, 255), HSL(240, 100, 92),
-    'LightSteelBlue1', 189)
+light_steel_blue1 = Colors.register(
+    RGB(215, 215, 255),
+    HSL(240, 100, 92),
+    'LightSteelBlue1',
+    189,
+)
 yellow2 = Colors.register(RGB(215, 255, 0), HSL(9, 100, 50), 'Yellow2', 190)
-dark_olive_green1 = Colors.register(RGB(215, 255, 95), HSL(75, 100, 68),
-    'DarkOliveGreen1', 191)
-dark_olive_green1 = Colors.register(RGB(215, 255, 135), HSL(80, 100, 76),
-    'DarkOliveGreen1', 192)
-dark_sea_green1 = Colors.register(RGB(215, 255, 175), HSL(90, 100, 84),
-    'DarkSeaGreen1', 193)
-honeydew2 = Colors.register(RGB(215, 255, 215), HSL(120, 100, 92),
-    'Honeydew2', 194)
-light_cyan1 = Colors.register(RGB(215, 255, 255), HSL(180, 100, 92),
-    'LightCyan1', 195)
+dark_olive_green1 = Colors.register(
+    RGB(215, 255, 95),
+    HSL(75, 100, 68),
+    'DarkOliveGreen1',
+    191,
+)
+dark_olive_green1 = Colors.register(
+    RGB(215, 255, 135),
+    HSL(80, 100, 76),
+    'DarkOliveGreen1',
+    192,
+)
+dark_sea_green1 = Colors.register(
+    RGB(215, 255, 175),
+    HSL(90, 100, 84),
+    'DarkSeaGreen1',
+    193,
+)
+honeydew2 = Colors.register(
+    RGB(215, 255, 215),
+    HSL(120, 100, 92),
+    'Honeydew2',
+    194,
+)
+light_cyan1 = Colors.register(
+    RGB(215, 255, 255),
+    HSL(180, 100, 92),
+    'LightCyan1',
+    195,
+)
 red1 = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red1', 196)
-deep_pink2 = Colors.register(RGB(255, 0, 95), HSL(37, 100, 50), 'DeepPink2',
-    197)
-deep_pink1 = Colors.register(RGB(255, 0, 135), HSL(28, 100, 50),
-    'DeepPink1', 198)
-deep_pink1 = Colors.register(RGB(255, 0, 175), HSL(18, 100, 50),
-    'DeepPink1', 199)
+deep_pink2 = Colors.register(
+    RGB(255, 0, 95),
+    HSL(37, 100, 50),
+    'DeepPink2',
+    197,
+)
+deep_pink1 = Colors.register(
+    RGB(255, 0, 135),
+    HSL(28, 100, 50),
+    'DeepPink1',
+    198,
+)
+deep_pink1 = Colors.register(
+    RGB(255, 0, 175),
+    HSL(18, 100, 50),
+    'DeepPink1',
+    199,
+)
 magenta2 = Colors.register(RGB(255, 0, 215), HSL(9, 100, 50), 'Magenta2', 200)
-magenta1 = Colors.register(RGB(255, 0, 255), HSL(300, 100, 50), 'Magenta1', 201
-    )
-orange_red1 = Colors.register(RGB(255, 95, 0), HSL(2, 100, 50),
-    'OrangeRed1', 202)
-indian_red1 = Colors.register(RGB(255, 95, 95), HSL(0, 100, 68),
-    'IndianRed1', 203)
-indian_red1 = Colors.register(RGB(255, 95, 135), HSL(345, 100, 68),
-    'IndianRed1', 204)
-hot_pink = Colors.register(RGB(255, 95, 175), HSL(330, 100, 68), 'HotPink', 205
-    )
-hot_pink = Colors.register(RGB(255, 95, 215), HSL(315, 100, 68), 'HotPink', 206
-    )
-medium_orchid1 = Colors.register(RGB(255, 95, 255), HSL(300, 100, 68),
-    'MediumOrchid1', 207)
-dark_orange = Colors.register(RGB(255, 135, 0), HSL(1, 100, 50),
-    'DarkOrange', 208)
+magenta1 = Colors.register(
+    RGB(255, 0, 255),
+    HSL(300, 100, 50),
+    'Magenta1',
+    201,
+)
+orange_red1 = Colors.register(
+    RGB(255, 95, 0),
+    HSL(2, 100, 50),
+    'OrangeRed1',
+    202,
+)
+indian_red1 = Colors.register(
+    RGB(255, 95, 95),
+    HSL(0, 100, 68),
+    'IndianRed1',
+    203,
+)
+indian_red1 = Colors.register(
+    RGB(255, 95, 135),
+    HSL(345, 100, 68),
+    'IndianRed1',
+    204,
+)
+hot_pink = Colors.register(
+    RGB(255, 95, 175),
+    HSL(330, 100, 68),
+    'HotPink',
+    205,
+)
+hot_pink = Colors.register(
+    RGB(255, 95, 215),
+    HSL(315, 100, 68),
+    'HotPink',
+    206,
+)
+medium_orchid1 = Colors.register(
+    RGB(255, 95, 255),
+    HSL(300, 100, 68),
+    'MediumOrchid1',
+    207,
+)
+dark_orange = Colors.register(
+    RGB(255, 135, 0),
+    HSL(1, 100, 50),
+    'DarkOrange',
+    208,
+)
 salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 68), 'Salmon1', 209)
-light_coral = Colors.register(RGB(255, 135, 135), HSL(0, 100, 76),
-    'LightCoral', 210)
-pale_violet_red1 = Colors.register(RGB(255, 135, 175), HSL(340, 100, 76),
-    'PaleVioletRed1', 211)
-orchid2 = Colors.register(RGB(255, 135, 215), HSL(320, 100, 76), 'Orchid2', 212
-    )
-orchid1 = Colors.register(RGB(255, 135, 255), HSL(300, 100, 76), 'Orchid1', 213
-    )
+light_coral = Colors.register(
+    RGB(255, 135, 135),
+    HSL(0, 100, 76),
+    'LightCoral',
+    210,
+)
+pale_violet_red1 = Colors.register(
+    RGB(255, 135, 175),
+    HSL(340, 100, 76),
+    'PaleVioletRed1',
+    211,
+)
+orchid2 = Colors.register(
+    RGB(255, 135, 215),
+    HSL(320, 100, 76),
+    'Orchid2',
+    212,
+)
+orchid1 = Colors.register(
+    RGB(255, 135, 255),
+    HSL(300, 100, 76),
+    'Orchid1',
+    213,
+)
 orange1 = Colors.register(RGB(255, 175, 0), HSL(1, 100, 50), 'Orange1', 214)
-sandy_brown = Colors.register(RGB(255, 175, 95), HSL(30, 100, 68),
-    'SandyBrown', 215)
-light_salmon1 = Colors.register(RGB(255, 175, 135), HSL(20, 100, 76),
-    'LightSalmon1', 216)
-light_pink1 = Colors.register(RGB(255, 175, 175), HSL(0, 100, 84),
-    'LightPink1', 217)
+sandy_brown = Colors.register(
+    RGB(255, 175, 95),
+    HSL(30, 100, 68),
+    'SandyBrown',
+    215,
+)
+light_salmon1 = Colors.register(
+    RGB(255, 175, 135),
+    HSL(20, 100, 76),
+    'LightSalmon1',
+    216,
+)
+light_pink1 = Colors.register(
+    RGB(255, 175, 175),
+    HSL(0, 100, 84),
+    'LightPink1',
+    217,
+)
 pink1 = Colors.register(RGB(255, 175, 215), HSL(330, 100, 84), 'Pink1', 218)
 plum1 = Colors.register(RGB(255, 175, 255), HSL(300, 100, 84), 'Plum1', 219)
 gold1 = Colors.register(RGB(255, 215, 0), HSL(0, 100, 50), 'Gold1', 220)
-light_goldenrod2 = Colors.register(RGB(255, 215, 95), HSL(45, 100, 68),
-    'LightGoldenrod2', 221)
-light_goldenrod2 = Colors.register(RGB(255, 215, 135), HSL(40, 100, 76),
-    'LightGoldenrod2', 222)
-navajo_white1 = Colors.register(RGB(255, 215, 175), HSL(30, 100, 84),
-    'NavajoWhite1', 223)
-misty_rose1 = Colors.register(RGB(255, 215, 215), HSL(0, 100, 92),
-    'MistyRose1', 224)
-thistle1 = Colors.register(RGB(255, 215, 255), HSL(300, 100, 92),
-    'Thistle1', 225)
+light_goldenrod2 = Colors.register(
+    RGB(255, 215, 95),
+    HSL(45, 100, 68),
+    'LightGoldenrod2',
+    221,
+)
+light_goldenrod2 = Colors.register(
+    RGB(255, 215, 135),
+    HSL(40, 100, 76),
+    'LightGoldenrod2',
+    222,
+)
+navajo_white1 = Colors.register(
+    RGB(255, 215, 175),
+    HSL(30, 100, 84),
+    'NavajoWhite1',
+    223,
+)
+misty_rose1 = Colors.register(
+    RGB(255, 215, 215),
+    HSL(0, 100, 92),
+    'MistyRose1',
+    224,
+)
+thistle1 = Colors.register(
+    RGB(255, 215, 255),
+    HSL(300, 100, 92),
+    'Thistle1',
+    225,
+)
 yellow1 = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow1', 226)
-light_goldenrod1 = Colors.register(RGB(255, 255, 95), HSL(60, 100, 68),
-    'LightGoldenrod1', 227)
+light_goldenrod1 = Colors.register(
+    RGB(255, 255, 95),
+    HSL(60, 100, 68),
+    'LightGoldenrod1',
+    227,
+)
 khaki1 = Colors.register(RGB(255, 255, 135), HSL(60, 100, 76), 'Khaki1', 228)
 wheat1 = Colors.register(RGB(255, 255, 175), HSL(60, 100, 84), 'Wheat1', 229)
-cornsilk1 = Colors.register(RGB(255, 255, 215), HSL(60, 100, 92),
-    'Cornsilk1', 230)
+cornsilk1 = Colors.register(
+    RGB(255, 255, 215),
+    HSL(60, 100, 92),
+    'Cornsilk1',
+    230,
+)
 grey100 = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'Grey100', 231)
 grey3 = Colors.register(RGB(8, 8, 8), HSL(0, 0, 3), 'Grey3', 232)
 grey7 = Colors.register(RGB(18, 18, 18), HSL(0, 0, 7), 'Grey7', 233)
@@ -411,15 +1034,37 @@ grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 81), 'Grey82', 252)
 grey85 = Colors.register(RGB(218, 218, 218), HSL(0, 0, 85), 'Grey85', 253)
 grey89 = Colors.register(RGB(228, 228, 228), HSL(0, 0, 89), 'Grey89', 254)
 grey93 = Colors.register(RGB(238, 238, 238), HSL(0, 0, 93), 'Grey93', 255)
-dark_gradient = ColorGradient(red1, orange_red1, dark_orange, orange1,
-    yellow1, yellow2, green_yellow, green1)
-light_gradient = ColorGradient(red1, orange_red1, dark_orange, orange1,
-    gold3, dark_olive_green3, yellow4, green3)
+
+dark_gradient = ColorGradient(
+    red1,
+    orange_red1,
+    dark_orange,
+    orange1,
+    yellow1,
+    yellow2,
+    green_yellow,
+    green1,
+)
+light_gradient = ColorGradient(
+    red1,
+    orange_red1,
+    dark_orange,
+    orange1,
+    gold3,
+    dark_olive_green3,
+    yellow4,
+    green3,
+)
 bg_gradient = ColorGradient(black)
+
+# Check if the background is light or dark. This is by no means a foolproof
+# method, but there is no reliable way to detect this.
 _colorfgbg = os.environ.get('COLORFGBG', '15;0').split(';')
-if _colorfgbg[-1] == str(white.xterm):
+if _colorfgbg[-1] == str(white.xterm):  # pragma: no cover
+    # Light background
     gradient = light_gradient
     primary = black
 else:
+    # Default, expect a dark background
     gradient = dark_gradient
     primary = white
diff --git a/progressbar/terminal/os_specific/posix.py b/progressbar/terminal/os_specific/posix.py
index 5b593c2..52a9560 100644
--- a/progressbar/terminal/os_specific/posix.py
+++ b/progressbar/terminal/os_specific/posix.py
@@ -1,3 +1,15 @@
 import sys
 import termios
 import tty
+
+
+def getch():
+    fd = sys.stdin.fileno()
+    old_settings = termios.tcgetattr(fd)  # type: ignore
+    try:
+        tty.setraw(sys.stdin.fileno())  # type: ignore
+        ch = sys.stdin.read(1)
+    finally:
+        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)  # type: ignore
+
+    return ch
diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py
index efdb404..425d349 100644
--- a/progressbar/terminal/os_specific/windows.py
+++ b/progressbar/terminal/os_specific/windows.py
@@ -1,33 +1,47 @@
-"""
+# ruff: noqa: N801
+'''
 Windows specific code for the terminal.

 Note that the naming convention here is non-pythonic because we are
 matching the Windows API naming.
-"""
+'''
 from __future__ import annotations
+
 import ctypes
 import enum
-from ctypes.wintypes import BOOL as _BOOL, CHAR as _CHAR, DWORD as _DWORD, HANDLE as _HANDLE, SHORT as _SHORT, UINT as _UINT, WCHAR as _WCHAR, WORD as _WORD
-_kernel32 = ctypes.windll.Kernel32
+from ctypes.wintypes import (
+    BOOL as _BOOL,
+    CHAR as _CHAR,
+    DWORD as _DWORD,
+    HANDLE as _HANDLE,
+    SHORT as _SHORT,
+    UINT as _UINT,
+    WCHAR as _WCHAR,
+    WORD as _WORD,
+)
+
+_kernel32 = ctypes.windll.Kernel32  # type: ignore
+
 _STD_INPUT_HANDLE = _DWORD(-10)
 _STD_OUTPUT_HANDLE = _DWORD(-11)


 class WindowsConsoleModeFlags(enum.IntFlag):
-    ENABLE_ECHO_INPUT = 4
-    ENABLE_EXTENDED_FLAGS = 128
-    ENABLE_INSERT_MODE = 32
-    ENABLE_LINE_INPUT = 2
-    ENABLE_MOUSE_INPUT = 16
-    ENABLE_PROCESSED_INPUT = 1
-    ENABLE_QUICK_EDIT_MODE = 64
-    ENABLE_WINDOW_INPUT = 8
-    ENABLE_VIRTUAL_TERMINAL_INPUT = 512
-    ENABLE_PROCESSED_OUTPUT = 1
-    ENABLE_WRAP_AT_EOL_OUTPUT = 2
-    ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4
-    DISABLE_NEWLINE_AUTO_RETURN = 8
-    ENABLE_LVB_GRID_WORLDWIDE = 16
+    ENABLE_ECHO_INPUT = 0x0004
+    ENABLE_EXTENDED_FLAGS = 0x0080
+    ENABLE_INSERT_MODE = 0x0020
+    ENABLE_LINE_INPUT = 0x0002
+    ENABLE_MOUSE_INPUT = 0x0010
+    ENABLE_PROCESSED_INPUT = 0x0001
+    ENABLE_QUICK_EDIT_MODE = 0x0040
+    ENABLE_WINDOW_INPUT = 0x0008
+    ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
+
+    ENABLE_PROCESSED_OUTPUT = 0x0001
+    ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
+    ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+    DISABLE_NEWLINE_AUTO_RETURN = 0x0008
+    ENABLE_LVB_GRID_WORLDWIDE = 0x0010

     def __str__(self):
         return f'{self.name} (0x{self.value:04X})'
@@ -35,57 +49,125 @@ class WindowsConsoleModeFlags(enum.IntFlag):

 _GetConsoleMode = _kernel32.GetConsoleMode
 _GetConsoleMode.restype = _BOOL
+
 _SetConsoleMode = _kernel32.SetConsoleMode
 _SetConsoleMode.restype = _BOOL
+
 _GetStdHandle = _kernel32.GetStdHandle
 _GetStdHandle.restype = _HANDLE
+
 _ReadConsoleInput = _kernel32.ReadConsoleInputA
 _ReadConsoleInput.restype = _BOOL
+
 _h_console_input = _GetStdHandle(_STD_INPUT_HANDLE)
 _input_mode = _DWORD()
 _GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode))
+
 _h_console_output = _GetStdHandle(_STD_OUTPUT_HANDLE)
 _output_mode = _DWORD()
 _GetConsoleMode(_HANDLE(_h_console_output), ctypes.byref(_output_mode))


 class _COORD(ctypes.Structure):
-    _fields_ = ('X', _SHORT), ('Y', _SHORT)
+    _fields_ = (('X', _SHORT), ('Y', _SHORT))


 class _FOCUS_EVENT_RECORD(ctypes.Structure):
-    _fields_ = ('bSetFocus', _BOOL),
+    _fields_ = (('bSetFocus', _BOOL),)


 class _KEY_EVENT_RECORD(ctypes.Structure):
-
-
     class _uchar(ctypes.Union):
-        _fields_ = ('UnicodeChar', _WCHAR), ('AsciiChar', _CHAR)
-    _fields_ = ('bKeyDown', _BOOL), ('wRepeatCount', _WORD), ('wVirtualKeyCode'
-        , _WORD), ('wVirtualScanCode', _WORD), ('uChar', _uchar), (
-        'dwControlKeyState', _DWORD)
+        _fields_ = (('UnicodeChar', _WCHAR), ('AsciiChar', _CHAR))
+
+    _fields_ = (
+        ('bKeyDown', _BOOL),
+        ('wRepeatCount', _WORD),
+        ('wVirtualKeyCode', _WORD),
+        ('wVirtualScanCode', _WORD),
+        ('uChar', _uchar),
+        ('dwControlKeyState', _DWORD),
+    )


 class _MENU_EVENT_RECORD(ctypes.Structure):
-    _fields_ = ('dwCommandId', _UINT),
+    _fields_ = (('dwCommandId', _UINT),)


 class _MOUSE_EVENT_RECORD(ctypes.Structure):
-    _fields_ = ('dwMousePosition', _COORD), ('dwButtonState', _DWORD), (
-        'dwControlKeyState', _DWORD), ('dwEventFlags', _DWORD)
+    _fields_ = (
+        ('dwMousePosition', _COORD),
+        ('dwButtonState', _DWORD),
+        ('dwControlKeyState', _DWORD),
+        ('dwEventFlags', _DWORD),
+    )


 class _WINDOW_BUFFER_SIZE_RECORD(ctypes.Structure):
-    _fields_ = ('dwSize', _COORD),
+    _fields_ = (('dwSize', _COORD),)


 class _INPUT_RECORD(ctypes.Structure):
+    class _Event(ctypes.Union):
+        _fields_ = (
+            ('KeyEvent', _KEY_EVENT_RECORD),
+            ('MouseEvent', _MOUSE_EVENT_RECORD),
+            ('WindowBufferSizeEvent', _WINDOW_BUFFER_SIZE_RECORD),
+            ('MenuEvent', _MENU_EVENT_RECORD),
+            ('FocusEvent', _FOCUS_EVENT_RECORD),
+        )

+    _fields_ = (('EventType', _WORD), ('Event', _Event))

-    class _Event(ctypes.Union):
-        _fields_ = ('KeyEvent', _KEY_EVENT_RECORD), ('MouseEvent',
-            _MOUSE_EVENT_RECORD), ('WindowBufferSizeEvent',
-            _WINDOW_BUFFER_SIZE_RECORD), ('MenuEvent', _MENU_EVENT_RECORD), (
-            'FocusEvent', _FOCUS_EVENT_RECORD)
-    _fields_ = ('EventType', _WORD), ('Event', _Event)
+
+def reset_console_mode() -> None:
+    _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value))
+    _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value))
+
+
+def set_console_mode() -> bool:
+    mode = (
+        _input_mode.value
+        | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_INPUT
+    )
+    _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode))
+
+    mode = (
+        _output_mode.value
+        | WindowsConsoleModeFlags.ENABLE_PROCESSED_OUTPUT
+        | WindowsConsoleModeFlags.ENABLE_VIRTUAL_TERMINAL_PROCESSING
+    )
+    return bool(_SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode)))
+
+
+def get_console_mode() -> int:
+    return _input_mode.value
+
+
+def set_text_color(color) -> None:
+    _kernel32.SetConsoleTextAttribute(_h_console_output, color)
+
+
+def print_color(text, color):
+    set_text_color(color)
+    print(text)  # noqa: T201
+    set_text_color(7)  # Reset to default color, grey
+
+
+def getch():
+    lp_buffer = (_INPUT_RECORD * 2)()
+    n_length = _DWORD(2)
+    lp_number_of_events_read = _DWORD()
+
+    _ReadConsoleInput(
+        _HANDLE(_h_console_input),
+        lp_buffer,
+        n_length,
+        ctypes.byref(lp_number_of_events_read),
+    )
+
+    char = lp_buffer[1].Event.KeyEvent.uChar.AsciiChar.decode('ascii')
+    if char == '\x00':
+        return None
+
+    return char
diff --git a/progressbar/terminal/stream.py b/progressbar/terminal/stream.py
index a5242d6..ee02a9d 100644
--- a/progressbar/terminal/stream.py
+++ b/progressbar/terminal/stream.py
@@ -1,41 +1,138 @@
 from __future__ import annotations
+
 import sys
 import typing
 from types import TracebackType
 from typing import Iterable, Iterator
-from progressbar import base

+from progressbar import base

-class TextIOOutputWrapper(base.TextIO):

+class TextIOOutputWrapper(base.TextIO):  # pragma: no cover
     def __init__(self, stream: base.TextIO):
         self.stream = stream

-    def __next__(self) ->str:
+    def close(self) -> None:
+        self.stream.close()
+
+    def fileno(self) -> int:
+        return self.stream.fileno()
+
+    def flush(self) -> None:
+        pass
+
+    def isatty(self) -> bool:
+        return self.stream.isatty()
+
+    def read(self, __n: int = -1) -> str:
+        return self.stream.read(__n)
+
+    def readable(self) -> bool:
+        return self.stream.readable()
+
+    def readline(self, __limit: int = -1) -> str:
+        return self.stream.readline(__limit)
+
+    def readlines(self, __hint: int = -1) -> list[str]:
+        return self.stream.readlines(__hint)
+
+    def seek(self, __offset: int, __whence: int = 0) -> int:
+        return self.stream.seek(__offset, __whence)
+
+    def seekable(self) -> bool:
+        return self.stream.seekable()
+
+    def tell(self) -> int:
+        return self.stream.tell()
+
+    def truncate(self, __size: int | None = None) -> int:
+        return self.stream.truncate(__size)
+
+    def writable(self) -> bool:
+        return self.stream.writable()
+
+    def writelines(self, __lines: Iterable[str]) -> None:
+        return self.stream.writelines(__lines)
+
+    def __next__(self) -> str:
         return self.stream.__next__()

-    def __iter__(self) ->Iterator[str]:
+    def __iter__(self) -> Iterator[str]:
         return self.stream.__iter__()

-    def __exit__(self, __t: (type[BaseException] | None), __value: (
-        BaseException | None), __traceback: (TracebackType | None)) ->None:
+    def __exit__(
+        self,
+        __t: type[BaseException] | None,
+        __value: BaseException | None,
+        __traceback: TracebackType | None,
+    ) -> None:
         return self.stream.__exit__(__t, __value, __traceback)

-    def __enter__(self) ->base.TextIO:
+    def __enter__(self) -> base.TextIO:
         return self.stream.__enter__()


 class LineOffsetStreamWrapper(TextIOOutputWrapper):
-    UP = '\x1b[F'
-    DOWN = '\x1b[B'
+    UP = '\033[F'
+    DOWN = '\033[B'

     def __init__(self, lines=0, stream=sys.stderr):
         self.lines = lines
         super().__init__(stream)

+    def write(self, data):
+        # Move the cursor up
+        self.stream.write(self.UP * self.lines)
+        # Print a carriage return to reset the cursor position
+        self.stream.write('\r')
+        # Print the data without newlines so we don't change the position
+        self.stream.write(data.rstrip('\n'))
+        # Move the cursor down
+        self.stream.write(self.DOWN * self.lines)
+
+        self.flush()
+

 class LastLineStream(TextIOOutputWrapper):
     line: str = ''

-    def __iter__(self) ->typing.Generator[str, typing.Any, typing.Any]:
+    def seekable(self) -> bool:
+        return False
+
+    def readable(self) -> bool:
+        return True
+
+    def read(self, __n: int = -1) -> str:
+        if __n < 0:
+            return self.line
+        else:
+            return self.line[:__n]
+
+    def readline(self, __limit: int = -1) -> str:
+        if __limit < 0:
+            return self.line
+        else:
+            return self.line[:__limit]
+
+    def write(self, data: str) -> int:
+        self.line = data
+        return len(data)
+
+    def truncate(self, __size: int | None = None) -> int:
+        if __size is None:
+            self.line = ''
+        else:
+            self.line = self.line[:__size]
+
+        return len(self.line)
+
+    def __iter__(self) -> typing.Generator[str, typing.Any, typing.Any]:
         yield self.line
+
+    def writelines(self, __lines: Iterable[str]) -> None:
+        line = ''
+        # Walk through the lines and take the last one
+        for line in __lines:  # noqa: B007
+            pass
+
+        self.line = line
diff --git a/progressbar/utils.py b/progressbar/utils.py
index edf654a..46d0cb2 100644
--- a/progressbar/utils.py
+++ b/progressbar/utils.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+
 import atexit
 import contextlib
 import datetime
@@ -9,24 +10,32 @@ import re
 import sys
 from types import TracebackType
 from typing import Iterable, Iterator
+
 from python_utils import types
 from python_utils.converters import scale_1024
 from python_utils.terminal import get_terminal_size
 from python_utils.time import epoch, format_time, timedelta_to_seconds
+
 from progressbar import base, env, terminal
+
 if types.TYPE_CHECKING:
     from .bar import ProgressBar, ProgressBarMixinBase
+
+# Make sure these are available for import
 assert timedelta_to_seconds is not None
 assert get_terminal_size is not None
 assert format_time is not None
 assert scale_1024 is not None
 assert epoch is not None
+
 StringT = types.TypeVar('StringT', bound=types.StringTypes)


-def deltas_to_seconds(*deltas, default: types.Optional[types.Type[
-    ValueError]]=ValueError) ->(int | float | None):
-    """
+def deltas_to_seconds(
+    *deltas,
+    default: types.Optional[types.Type[ValueError]] = ValueError,
+) -> int | float | None:
+    '''
     Convert timedeltas and seconds as int to seconds as float while coalescing.

     >>> deltas_to_seconds(datetime.timedelta(seconds=1, milliseconds=234))
@@ -49,40 +58,60 @@ def deltas_to_seconds(*deltas, default: types.Optional[types.Type[
     ValueError: No valid deltas passed to `deltas_to_seconds`
     >>> deltas_to_seconds(default=0.0)
     0.0
-    """
-    pass
+    '''
+    for delta in deltas:
+        if delta is None:
+            continue
+        if isinstance(delta, datetime.timedelta):
+            return timedelta_to_seconds(delta)
+        elif not isinstance(delta, float):
+            return float(delta)
+        else:
+            return delta
+
+    if default is ValueError:
+        raise ValueError('No valid deltas passed to `deltas_to_seconds`')
+    else:
+        # mypy doesn't understand the `default is ValueError` check
+        return default  # type: ignore


-def no_color(value: StringT) ->StringT:
-    """
+def no_color(value: StringT) -> StringT:
+    '''
     Return the `value` without ANSI escape codes.

-    >>> no_color(b'[1234]abc')
+    >>> no_color(b'\u001b[1234]abc')
     b'abc'
-    >>> str(no_color(u'[1234]abc'))
+    >>> str(no_color(u'\u001b[1234]abc'))
     'abc'
-    >>> str(no_color('[1234]abc'))
+    >>> str(no_color('\u001b[1234]abc'))
     'abc'
     >>> no_color(123)
     Traceback (most recent call last):
     ...
     TypeError: `value` must be a string or bytes, got 123
-    """
-    pass
-
-
-def len_color(value: types.StringTypes) ->int:
-    """
+    '''
+    if isinstance(value, bytes):
+        pattern: bytes = bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]'
+        return re.sub(pattern, b'', value)  # type: ignore
+    elif isinstance(value, str):
+        return re.sub('\x1b\\[.*?[@-~]', '', value)  # type: ignore
+    else:
+        raise TypeError('`value` must be a string or bytes, got %r' % value)
+
+
+def len_color(value: types.StringTypes) -> int:
+    '''
     Return the length of `value` without ANSI escape codes.

-    >>> len_color(b'[1234]abc')
+    >>> len_color(b'\u001b[1234]abc')
     3
-    >>> len_color(u'[1234]abc')
+    >>> len_color(u'\u001b[1234]abc')
     3
-    >>> len_color('[1234]abc')
+    >>> len_color('\u001b[1234]abc')
     3
-    """
-    pass
+    '''
+    return len(no_color(value))


 class WrappingIO:
@@ -92,34 +121,122 @@ class WrappingIO:
     listeners: set
     needs_clear: bool = False

-    def __init__(self, target: base.IO, capturing: bool=False, listeners:
-        types.Optional[types.Set[ProgressBar]]=None) ->None:
+    def __init__(
+        self,
+        target: base.IO,
+        capturing: bool = False,
+        listeners: types.Optional[types.Set[ProgressBar]] = None,
+    ) -> None:
         self.buffer = io.StringIO()
         self.target = target
         self.capturing = capturing
         self.listeners = listeners or set()
         self.needs_clear = False

-    def __enter__(self) ->WrappingIO:
+    def write(self, value: str) -> int:
+        ret = 0
+        if self.capturing:
+            ret += self.buffer.write(value)
+            if '\n' in value:  # pragma: no branch
+                self.needs_clear = True
+                for listener in self.listeners:  # pragma: no branch
+                    listener.update()
+        else:
+            ret += self.target.write(value)
+            if '\n' in value:  # pragma: no branch
+                self.flush_target()
+
+        return ret
+
+    def flush(self) -> None:
+        self.buffer.flush()
+
+    def _flush(self) -> None:
+        if value := self.buffer.getvalue():
+            self.flush()
+            self.target.write(value)
+            self.buffer.seek(0)
+            self.buffer.truncate(0)
+            self.needs_clear = False
+
+        # when explicitly flushing, always flush the target as well
+        self.flush_target()
+
+    def flush_target(self) -> None:  # pragma: no cover
+        if not self.target.closed and getattr(self.target, 'flush', None):
+            self.target.flush()
+
+    def __enter__(self) -> WrappingIO:
         return self

-    def __next__(self) ->str:
+    def fileno(self) -> int:
+        return self.target.fileno()
+
+    def isatty(self) -> bool:
+        return self.target.isatty()
+
+    def read(self, n: int = -1) -> str:
+        return self.target.read(n)
+
+    def readable(self) -> bool:
+        return self.target.readable()
+
+    def readline(self, limit: int = -1) -> str:
+        return self.target.readline(limit)
+
+    def readlines(self, hint: int = -1) -> list[str]:
+        return self.target.readlines(hint)
+
+    def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
+        return self.target.seek(offset, whence)
+
+    def seekable(self) -> bool:
+        return self.target.seekable()
+
+    def tell(self) -> int:
+        return self.target.tell()
+
+    def truncate(self, size: types.Optional[int] = None) -> int:
+        return self.target.truncate(size)
+
+    def writable(self) -> bool:
+        return self.target.writable()
+
+    def writelines(self, lines: Iterable[str]) -> None:
+        return self.target.writelines(lines)
+
+    def close(self) -> None:
+        self.flush()
+        self.target.close()
+
+    def __next__(self) -> str:
         return self.target.__next__()

-    def __iter__(self) ->Iterator[str]:
+    def __iter__(self) -> Iterator[str]:
         return self.target.__iter__()

-    def __exit__(self, __t: (type[BaseException] | None), __value: (
-        BaseException | None), __traceback: (TracebackType | None)) ->None:
+    def __exit__(
+        self,
+        __t: type[BaseException] | None,
+        __value: BaseException | None,
+        __traceback: TracebackType | None,
+    ) -> None:
         self.close()


 class StreamWrapper:
-    """Wrap stdout and stderr globally."""
+    '''Wrap stdout and stderr globally.'''
+
     stdout: base.TextIO | WrappingIO
     stderr: base.TextIO | WrappingIO
-    original_excepthook: types.Callable[[types.Type[BaseException],
-        BaseException, TracebackType | None], None]
+    original_excepthook: types.Callable[
+        [
+            types.Type[BaseException],
+            BaseException,
+            TracebackType | None,
+        ],
+        None,
+    ]
     wrapped_stdout: int = 0
     wrapped_stderr: int = 0
     wrapped_excepthook: int = 0
@@ -135,14 +252,134 @@ class StreamWrapper:
         self.wrapped_excepthook = 0
         self.capturing = 0
         self.listeners = set()
-        if env.env_flag('WRAP_STDOUT', default=False):
+
+        if env.env_flag('WRAP_STDOUT', default=False):  # pragma: no cover
             self.wrap_stdout()
-        if env.env_flag('WRAP_STDERR', default=False):
+
+        if env.env_flag('WRAP_STDERR', default=False):  # pragma: no cover
+            self.wrap_stderr()
+
+    def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None:
+        if bar:  # pragma: no branch
+            self.listeners.add(bar)
+
+        self.capturing += 1
+        self.update_capturing()
+
+    def stop_capturing(self, bar: ProgressBarMixinBase | None = None) -> None:
+        if bar:  # pragma: no branch
+            with contextlib.suppress(KeyError):
+                self.listeners.remove(bar)
+
+        self.capturing -= 1
+        self.update_capturing()
+
+    def update_capturing(self) -> None:  # pragma: no cover
+        if isinstance(self.stdout, WrappingIO):
+            self.stdout.capturing = self.capturing > 0
+
+        if isinstance(self.stderr, WrappingIO):
+            self.stderr.capturing = self.capturing > 0
+
+        if self.capturing <= 0:
+            self.flush()
+
+    def wrap(self, stdout: bool = False, stderr: bool = False) -> None:
+        if stdout:
+            self.wrap_stdout()
+
+        if stderr:
             self.wrap_stderr()

+    def wrap_stdout(self) -> WrappingIO:
+        self.wrap_excepthook()
+
+        if not self.wrapped_stdout:
+            self.stdout = sys.stdout = WrappingIO(  # type: ignore
+                self.original_stdout,
+                listeners=self.listeners,
+            )
+        self.wrapped_stdout += 1
+
+        return sys.stdout  # type: ignore
+
+    def wrap_stderr(self) -> WrappingIO:
+        self.wrap_excepthook()
+
+        if not self.wrapped_stderr:
+            self.stderr = sys.stderr = WrappingIO(  # type: ignore
+                self.original_stderr,
+                listeners=self.listeners,
+            )
+        self.wrapped_stderr += 1
+
+        return sys.stderr  # type: ignore
+
+    def unwrap_excepthook(self) -> None:
+        if self.wrapped_excepthook:
+            self.wrapped_excepthook -= 1
+            sys.excepthook = self.original_excepthook
+
+    def wrap_excepthook(self) -> None:
+        if not self.wrapped_excepthook:
+            logger.debug('wrapping excepthook')
+            self.wrapped_excepthook += 1
+            sys.excepthook = self.excepthook
+
+    def unwrap(self, stdout: bool = False, stderr: bool = False) -> None:
+        if stdout:
+            self.unwrap_stdout()
+
+        if stderr:
+            self.unwrap_stderr()
+
+    def unwrap_stdout(self) -> None:
+        if self.wrapped_stdout > 1:
+            self.wrapped_stdout -= 1
+        else:
+            sys.stdout = self.original_stdout
+            self.wrapped_stdout = 0
+
+    def unwrap_stderr(self) -> None:
+        if self.wrapped_stderr > 1:
+            self.wrapped_stderr -= 1
+        else:
+            sys.stderr = self.original_stderr
+            self.wrapped_stderr = 0
+
+    def needs_clear(self) -> bool:  # pragma: no cover
+        stdout_needs_clear = getattr(self.stdout, 'needs_clear', False)
+        stderr_needs_clear = getattr(self.stderr, 'needs_clear', False)
+        return stderr_needs_clear or stdout_needs_clear
+
+    def flush(self) -> None:
+        if self.wrapped_stdout and isinstance(self.stdout, WrappingIO):
+            try:
+                self.stdout._flush()
+            except io.UnsupportedOperation:  # pragma: no cover
+                self.wrapped_stdout = False
+                logger.warning(
+                    'Disabling stdout redirection, %r is not seekable',
+                    sys.stdout,
+                )
+
+        if self.wrapped_stderr and isinstance(self.stderr, WrappingIO):
+            try:
+                self.stderr._flush()
+            except io.UnsupportedOperation:  # pragma: no cover
+                self.wrapped_stderr = False
+                logger.warning(
+                    'Disabling stderr redirection, %r is not seekable',
+                    sys.stderr,
+                )
+
+    def excepthook(self, exc_type, exc_value, exc_traceback):
+        self.original_excepthook(exc_type, exc_value, exc_traceback)
+        self.flush()
+

 class AttributeDict(dict):
-    """
+    '''
     A dict that can be accessed with .attribute.

     >>> attrs = AttributeDict(spam=123)
@@ -185,18 +422,18 @@ class AttributeDict(dict):
     Traceback (most recent call last):
     ...
     AttributeError: No such attribute: spam
-    """
+    '''

-    def __getattr__(self, name: str) ->int:
+    def __getattr__(self, name: str) -> int:
         if name in self:
             return self[name]
         else:
             raise AttributeError(f'No such attribute: {name}')

-    def __setattr__(self, name: str, value: int) ->None:
+    def __setattr__(self, name: str, value: int) -> None:
         self[name] = value

-    def __delattr__(self, name: str) ->None:
+    def __delattr__(self, name: str) -> None:
         if name in self:
             del self[name]
         else:
diff --git a/progressbar/widgets.py b/progressbar/widgets.py
index be97fda..e5046b6 100644
--- a/progressbar/widgets.py
+++ b/progressbar/widgets.py
@@ -1,27 +1,49 @@
 from __future__ import annotations
+
 import abc
 import contextlib
 import datetime
 import functools
 import logging
 import typing
+
+# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the
+# `types` module
 from typing import ClassVar
+
 from python_utils import containers, converters, types
+
 from . import algorithms, base, terminal, utils
 from .terminal import colors
+
 if types.TYPE_CHECKING:
     from .bar import ProgressBarMixinBase
+
 logger = logging.getLogger(__name__)
+
 MAX_DATE = datetime.date.max
 MAX_TIME = datetime.time.max
 MAX_DATETIME = datetime.datetime.max
+
 Data = types.Dict[str, types.Any]
 FormatString = typing.Optional[str]
+
 T = typing.TypeVar('T')


+def string_or_lambda(input_):
+    if isinstance(input_, str):
+
+        def render_input(progress, data, width):
+            return input_ % data
+
+        return render_input
+    else:
+        return input_
+
+
 def create_wrapper(wrapper):
-    """Convert a wrapper tuple or format string to a format string.
+    '''Convert a wrapper tuple or format string to a format string.

     >>> create_wrapper('')

@@ -30,20 +52,63 @@ def create_wrapper(wrapper):

     >>> print(create_wrapper(('a', 'b')))
     a{}b
-    """
-    pass
+    '''
+    if isinstance(wrapper, tuple) and len(wrapper) == 2:
+        a, b = wrapper
+        wrapper = (a or '') + '{}' + (b or '')
+    elif not wrapper:
+        return None
+
+    if isinstance(wrapper, str):
+        assert '{}' in wrapper, 'Expected string with {} for formatting'
+    else:
+        raise RuntimeError(  # noqa: TRY004
+            'Pass either a begin/end string as a tuple or a template string '
+            'with `{}`',
+        )
+
+    return wrapper


 def wrapper(function, wrapper_):
-    """Wrap the output of a function in a template string or a tuple with
+    '''Wrap the output of a function in a template string or a tuple with
     begin/end strings.

-    """
-    pass
+    '''
+    wrapper_ = create_wrapper(wrapper_)
+    if not wrapper_:
+        return function
+
+    @functools.wraps(function)
+    def wrap(*args, **kwargs):
+        return wrapper_.format(function(*args, **kwargs))
+
+    return wrap
+
+
+def create_marker(marker, wrap=None):
+    def _marker(progress, data, width):
+        if (
+            progress.max_value is not base.UnknownLength
+            and progress.max_value > 0
+        ):
+            length = int(progress.value / progress.max_value * width)
+            return marker * length
+        else:
+            return marker
+
+    if isinstance(marker, str):
+        marker = converters.to_unicode(marker)
+        assert (
+            utils.len_color(marker) == 1
+        ), 'Markers are required to be 1 char'
+        return wrapper(_marker, wrap)
+    else:
+        return wrapper(marker, wrap)


 class FormatWidgetMixin(abc.ABC):
-    """Mixin to format widgets using a formatstring.
+    '''Mixin to format widgets using a formatstring.

     Variables available:
      - max_value: The maximum value (can be None with iterators)
@@ -56,15 +121,27 @@ class FormatWidgetMixin(abc.ABC):
      - time_elapsed: Shortcut for HH:MM:SS time since the bar started including
        days
      - percentage: Percentage as a float
-    """
+    '''

-    def __init__(self, format: str, new_style: bool=False, **kwargs):
+    def __init__(self, format: str, new_style: bool = False, **kwargs):
         self.new_style = new_style
         self.format = format

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None) ->str:
-        """Formats the widget into a string."""
+    def get_format(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ) -> str:
+        return format or self.format
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ) -> str:
+        '''Formats the widget into a string.'''
         format_ = self.get_format(progress, data, format)
         try:
             if self.new_style:
@@ -72,13 +149,16 @@ class FormatWidgetMixin(abc.ABC):
             else:
                 return format_ % data
         except (TypeError, KeyError):
-            logger.exception('Error while formatting %r with data: %r',
-                format_, data)
+            logger.exception(
+                'Error while formatting %r with data: %r',
+                format_,
+                data,
+            )
             raise


 class WidthWidgetMixin(abc.ABC):
-    """Mixing to make sure widgets are only visible if the screen is within a
+    '''Mixing to make sure widgets are only visible if the screen is within a
     specified size range so the progressbar fits on both large and small
     screens.

@@ -100,12 +180,22 @@ class WidthWidgetMixin(abc.ABC):
     >>> Progress.term_width = 11
     >>> WidthWidgetMixin(5, 10).check_size(Progress)
     False
-    """
+    '''

     def __init__(self, min_width=None, max_width=None, **kwargs):
         self.min_width = min_width
         self.max_width = max_width

+    def check_size(self, progress: ProgressBarMixinBase):
+        max_width = self.max_width
+        min_width = self.min_width
+        if min_width and min_width > progress.term_width:
+            return False
+        elif max_width and max_width < progress.term_width:  # noqa: SIM103
+            return False
+        else:
+            return True
+

 class TGradientColors(typing.TypedDict):
     fg: types.Optional[terminal.OptionalColor | None]
@@ -118,7 +208,7 @@ class TFixedColors(typing.TypedDict):


 class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta):
-    """The base class for all widgets.
+    '''The base class for all widgets.

     The ProgressBar will call the widget's update value when the widget should
     be updated. The widget's size may change between calls, but the widget may
@@ -144,61 +234,102 @@ class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta):
        progressbar can be reused. Some widgets such as the FormatCustomText
        require the shared state so this needs to be optional

-    """
+    '''
+
     copy = True

     @abc.abstractmethod
-    def __call__(self, progress: ProgressBarMixinBase, data: Data) ->str:
-        """Updates the widget.
+    def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str:
+        '''Updates the widget.

         progress - a reference to the calling ProgressBar
-        """
-    _fixed_colors: ClassVar[TFixedColors] = TFixedColors(fg_none=None,
-        bg_none=None)
-    _gradient_colors: ClassVar[TGradientColors] = TGradientColors(fg=None,
-        bg=None)
+        '''
+
+    _fixed_colors: ClassVar[TFixedColors] = TFixedColors(
+        fg_none=None,
+        bg_none=None,
+    )
+    _gradient_colors: ClassVar[TGradientColors] = TGradientColors(
+        fg=None,
+        bg=None,
+    )
+    # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict()
+    # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | None]] = (
+    #     dict())
     _len: typing.Callable[[str | bytes], int] = len

-    def __init__(self, *args, fixed_colors=None, gradient_colors=None, **kwargs
-        ):
+    @functools.cached_property
+    def uses_colors(self):
+        for value in self._gradient_colors.values():  # pragma: no branch
+            if value is not None:  # pragma: no branch
+                return True
+
+        return any(value is not None for value in self._fixed_colors.values())
+
+    def _apply_colors(self, text: str, data: Data) -> str:
+        if self.uses_colors:
+            return terminal.apply_colors(
+                text,
+                data.get('percentage'),
+                **self._gradient_colors,
+                **self._fixed_colors,
+            )
+        else:
+            return text
+
+    def __init__(
+        self,
+        *args,
+        fixed_colors=None,
+        gradient_colors=None,
+        **kwargs,
+    ):
         if fixed_colors is not None:
             self._fixed_colors.update(fixed_colors)
+
         if gradient_colors is not None:
             self._gradient_colors.update(gradient_colors)
+
         if self.uses_colors:
             self._len = utils.len_color
+
         super().__init__(*args, **kwargs)


 class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta):
-    """The base class for all variable width widgets.
+    '''The base class for all variable width widgets.

     This widget is much like the \\hfill command in TeX, it will expand to
     fill the line. You can use more than one in the same line, and they will
     all have the same width, and together will fill the line.
-    """
+    '''

     @abc.abstractmethod
-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width: int=0
-        ) ->str:
-        """Updates the widget providing the total width the widget must fill.
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+    ) -> str:
+        '''Updates the widget providing the total width the widget must fill.

         progress - a reference to the calling ProgressBar
         width - The total width the widget must fill
-        """
+        '''


 class TimeSensitiveWidgetBase(WidgetBase, metaclass=abc.ABCMeta):
-    """The base class for all time sensitive widgets.
+    '''The base class for all time sensitive widgets.

     Some widgets like timers would become out of date unless updated at least
     every `INTERVAL`
-    """
+    '''
+
     INTERVAL = datetime.timedelta(milliseconds=100)


 class FormatLabel(FormatWidgetMixin, WidgetBase):
-    """Displays a formatted label.
+    '''Displays a formatted label.

     >>> label = FormatLabel('%(value)s', min_width=5, max_width=10)
     >>> class Progress:
@@ -207,41 +338,54 @@ class FormatLabel(FormatWidgetMixin, WidgetBase):
     >>> str(label(Progress, dict(value='test')))
     'test ::  test '

-    """
+    '''
+
     mapping: ClassVar[types.Dict[str, types.Tuple[str, types.Any]]] = dict(
-        finished=('end_time', None), last_update=('last_update_time', None),
-        max=('max_value', None), seconds=('seconds_elapsed', None), start=(
-        'start_time', None), elapsed=('total_seconds_elapsed', utils.
-        format_time), value=('value', None))
+        finished=('end_time', None),
+        last_update=('last_update_time', None),
+        max=('max_value', None),
+        seconds=('seconds_elapsed', None),
+        start=('start_time', None),
+        elapsed=('total_seconds_elapsed', utils.format_time),
+        value=('value', None),
+    )

     def __init__(self, format: str, **kwargs):
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         WidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ):
         for name, (key, transform) in self.mapping.items():
             with contextlib.suppress(KeyError, ValueError, IndexError):
                 if transform is None:
                     data[name] = data[key]
                 else:
                     data[name] = transform(data[key])
+
         return FormatWidgetMixin.__call__(self, progress, data, format)


 class Timer(FormatLabel, TimeSensitiveWidgetBase):
-    """WidgetBase which displays the elapsed seconds."""
+    '''WidgetBase which displays the elapsed seconds.'''

     def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs):
         if '%s' in format and '%(elapsed)s' not in format:
             format = format.replace('%s', '%(elapsed)s')
+
         FormatLabel.__init__(self, format=format, **kwargs)
         TimeSensitiveWidgetBase.__init__(self, **kwargs)
+
+    # This is exposed as a static method for backwards compatibility
     format_time = staticmethod(utils.format_time)


 class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta):
-    """
+    '''
     Mixing for widgets that average multiple measurements.

     Note that samples can be either an integer or a timedelta to indicate a
@@ -270,37 +414,65 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta):

     >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0)
     True
-    """
-
-    def __init__(self, samples=datetime.timedelta(seconds=2), key_prefix=
-        None, **kwargs):
+    '''
+
+    def __init__(
+        self,
+        samples=datetime.timedelta(seconds=2),
+        key_prefix=None,
+        **kwargs,
+    ):
         self.samples = samples
         self.key_prefix = (key_prefix or self.__class__.__name__) + '_'
         TimeSensitiveWidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, delta:
-        bool=False):
+    def get_sample_times(self, progress: ProgressBarMixinBase, data: Data):
+        return progress.extra.setdefault(
+            f'{self.key_prefix}sample_times',
+            containers.SliceableDeque(),
+        )
+
+    def get_sample_values(self, progress: ProgressBarMixinBase, data: Data):
+        return progress.extra.setdefault(
+            f'{self.key_prefix}sample_values',
+            containers.SliceableDeque(),
+        )
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        delta: bool = False,
+    ):
         sample_times = self.get_sample_times(progress, data)
         sample_values = self.get_sample_values(progress, data)
+
         if sample_times:
             sample_time = sample_times[-1]
         else:
             sample_time = datetime.datetime.min
+
         if progress.last_update_time - sample_time > self.INTERVAL:
+            # Add a sample but limit the size to `num_samples`
             sample_times.append(progress.last_update_time)
             sample_values.append(progress.value)
+
             if isinstance(self.samples, datetime.timedelta):
                 minimum_time = progress.last_update_time - self.samples
                 minimum_value = sample_values[-1]
-                while sample_times[2:] and minimum_time > sample_times[1
-                    ] and minimum_value > sample_values[1]:
+                while (
+                    sample_times[2:]
+                    and minimum_time > sample_times[1]
+                    and minimum_value > sample_values[1]
+                ):
                     sample_times.pop(0)
                     sample_values.pop(0)
             elif len(sample_times) > self.samples:
                 sample_times.pop(0)
                 sample_values.pop(0)
+
         if delta:
-            if (delta_time := sample_times[-1] - sample_times[0]):
+            if delta_time := sample_times[-1] - sample_times[0]:
                 delta_value = sample_values[-1] - sample_values[0]
                 return delta_time, delta_value
             else:
@@ -310,13 +482,20 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta):


 class ETA(Timer):
-    """WidgetBase which attempts to estimate the time of arrival."""
-
-    def __init__(self, format_not_started='ETA:  --:--:--', format_finished
-        ='Time: %(elapsed)8s', format='ETA:  %(eta)8s', format_zero=
-        'ETA:  00:00:00', format_na='ETA:      N/A', **kwargs):
+    '''WidgetBase which attempts to estimate the time of arrival.'''
+
+    def __init__(
+        self,
+        format_not_started='ETA:  --:--:--',
+        format_finished='Time: %(elapsed)8s',
+        format='ETA:  %(eta)8s',
+        format_zero='ETA:  00:00:00',
+        format_na='ETA:      N/A',
+        **kwargs,
+    ):
         if '%s' in format and '%(eta)s' not in format:
             format = format.replace('%s', '%(eta)s')
+
         Timer.__init__(self, **kwargs)
         self.format_not_started = format_not_started
         self.format_finished = format_finished
@@ -324,29 +503,53 @@ class ETA(Timer):
         self.format_zero = format_zero
         self.format_NA = format_na

-    def _calculate_eta(self, progress: ProgressBarMixinBase, data: Data,
-        value, elapsed):
-        """Updates the widget to show the ETA or total time when finished."""
-        pass
-
-    def __call__(self, progress: ProgressBarMixinBase, data: Data, value=
-        None, elapsed=None):
-        """Updates the widget to show the ETA or total time when finished."""
+    def _calculate_eta(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        value,
+        elapsed,
+    ):
+        '''Updates the widget to show the ETA or total time when finished.'''
+        if elapsed:
+            # The max() prevents zero division errors
+            per_item = elapsed.total_seconds() / max(value, 1e-6)
+            remaining = progress.max_value - data['value']
+            return remaining * per_item
+        else:
+            return 0
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        value=None,
+        elapsed=None,
+    ):
+        '''Updates the widget to show the ETA or total time when finished.'''
         if value is None:
             value = data['value']
+
         if elapsed is None:
             elapsed = data['time_elapsed']
+
         eta_na = False
         try:
-            data['eta_seconds'] = self._calculate_eta(progress, data, value
-                =value, elapsed=elapsed)
+            data['eta_seconds'] = self._calculate_eta(
+                progress,
+                data,
+                value=value,
+                elapsed=elapsed,
+            )
         except TypeError:
             data['eta_seconds'] = None
             eta_na = True
+
         data['eta'] = None
         if data['eta_seconds']:
             with contextlib.suppress(ValueError, OverflowError):
                 data['eta'] = utils.format_time(data['eta_seconds'])
+
         if data['value'] == progress.min_value:
             fmt = self.format_not_started
         elif progress.end_time:
@@ -357,141 +560,230 @@ class ETA(Timer):
             fmt = self.format_NA
         else:
             fmt = self.format_zero
+
         return Timer.__call__(self, progress, data, format=fmt)


 class AbsoluteETA(ETA):
-    """Widget which attempts to estimate the absolute time of arrival."""
-
-    def __init__(self, format_not_started=
-        'Estimated finish time:  ----/--/-- --:--:--', format_finished=
-        'Finished at: %(elapsed)s', format='Estimated finish time: %(eta)s',
-        **kwargs):
-        ETA.__init__(self, format_not_started=format_not_started,
-            format_finished=format_finished, format=format, **kwargs)
+    '''Widget which attempts to estimate the absolute time of arrival.'''
+
+    def _calculate_eta(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        value,
+        elapsed,
+    ):
+        eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed)
+        now = datetime.datetime.now()
+        try:
+            return now + datetime.timedelta(seconds=eta_seconds)
+        except OverflowError:  # pragma: no cover
+            return datetime.datetime.max
+
+    def __init__(
+        self,
+        format_not_started='Estimated finish time:  ----/--/-- --:--:--',
+        format_finished='Finished at: %(elapsed)s',
+        format='Estimated finish time: %(eta)s',
+        **kwargs,
+    ):
+        ETA.__init__(
+            self,
+            format_not_started=format_not_started,
+            format_finished=format_finished,
+            format=format,
+            **kwargs,
+        )


 class AdaptiveETA(ETA, SamplesMixin):
-    """WidgetBase which attempts to estimate the time of arrival.
+    '''WidgetBase which attempts to estimate the time of arrival.

     Uses a sampled average of the speed based on the 10 last updates.
     Very convenient for resuming the progress halfway.
-    """
+    '''
+
     exponential_smoothing: bool
     exponential_smoothing_factor: float

-    def __init__(self, exponential_smoothing=True,
-        exponential_smoothing_factor=0.1, **kwargs):
+    def __init__(
+        self,
+        exponential_smoothing=True,
+        exponential_smoothing_factor=0.1,
+        **kwargs,
+    ):
         self.exponential_smoothing = exponential_smoothing
         self.exponential_smoothing_factor = exponential_smoothing_factor
         ETA.__init__(self, **kwargs)
         SamplesMixin.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, value=
-        None, elapsed=None):
-        elapsed, value = SamplesMixin.__call__(self, progress, data, delta=True
-            )
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        value=None,
+        elapsed=None,
+    ):
+        elapsed, value = SamplesMixin.__call__(
+            self,
+            progress,
+            data,
+            delta=True,
+        )
         if not elapsed:
             value = None
             elapsed = 0
+
         return ETA.__call__(self, progress, data, value=value, elapsed=elapsed)


 class SmoothingETA(ETA):
-    """
+    '''
     WidgetBase which attempts to estimate the time of arrival using an
     exponential moving average (EMA) of the speed.

     EMA applies more weight to recent data points and less to older ones,
     and doesn't require storing all past values. This approach works well
     with varying data points and smooths out fluctuations effectively.
-    """
+    '''
+
     smoothing_algorithm: algorithms.SmoothingAlgorithm
     smoothing_parameters: dict[str, float]

-    def __init__(self, smoothing_algorithm: type[algorithms.
-        SmoothingAlgorithm]=algorithms.ExponentialMovingAverage,
-        smoothing_parameters: (dict[str, float] | None)=None, **kwargs):
+    def __init__(
+        self,
+        smoothing_algorithm: type[
+            algorithms.SmoothingAlgorithm
+        ] = algorithms.ExponentialMovingAverage,
+        smoothing_parameters: dict[str, float] | None = None,
+        **kwargs,
+    ):
         self.smoothing_parameters = smoothing_parameters or {}
-        self.smoothing_algorithm = smoothing_algorithm(**self.
-            smoothing_parameters or {})
+        self.smoothing_algorithm = smoothing_algorithm(
+            **(self.smoothing_parameters or {}),
+        )
         ETA.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, value=
-        None, elapsed=None):
-        if value is None:
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        value=None,
+        elapsed=None,
+    ):
+        if value is None:  # pragma: no branch
             value = data['value']
-        if elapsed is None:
+
+        if elapsed is None:  # pragma: no branch
             elapsed = data['time_elapsed']
+
         self.smoothing_algorithm.update(value, elapsed)
         return ETA.__call__(self, progress, data, value=value, elapsed=elapsed)


 class DataSize(FormatWidgetMixin, WidgetBase):
-    """
+    '''
     Widget for showing an amount of data transferred/processed.

     Automatically formats the value (assumed to be a count of bytes) with an
     appropriate sized unit, based on the IEC binary prefixes (powers of 1024).
-    """
-
-    def __init__(self, variable='value', format=
-        '%(scaled)5.1f %(prefix)s%(unit)s', unit='B', prefixes=('', 'Ki',
-        'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), **kwargs):
+    '''
+
+    def __init__(
+        self,
+        variable='value',
+        format='%(scaled)5.1f %(prefix)s%(unit)s',
+        unit='B',
+        prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'),
+        **kwargs,
+    ):
         self.variable = variable
         self.unit = unit
         self.prefixes = prefixes
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         WidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ):
         value = data[self.variable]
         if value is not None:
             scaled, power = utils.scale_1024(value, len(self.prefixes))
         else:
             scaled = power = 0
+
         data['scaled'] = scaled
         data['prefix'] = self.prefixes[power]
         data['unit'] = self.unit
+
         return FormatWidgetMixin.__call__(self, progress, data, format)


 class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase):
-    """
+    '''
     Widget for showing the current transfer speed (useful for file transfers).
-    """
-
-    def __init__(self, format='%(scaled)5.1f %(prefix)s%(unit)-s/s',
-        inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s', unit='B',
-        prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'), **kwargs
-        ):
+    '''
+
+    def __init__(
+        self,
+        format='%(scaled)5.1f %(prefix)s%(unit)-s/s',
+        inverse_format='%(scaled)5.1f s/%(prefix)s%(unit)-s',
+        unit='B',
+        prefixes=('', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'),
+        **kwargs,
+    ):
         self.unit = unit
         self.prefixes = prefixes
         self.inverse_format = inverse_format
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         TimeSensitiveWidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data, value=None,
-        total_seconds_elapsed=None):
-        """Updates the widget with the current SI prefixed speed."""
+    def _speed(self, value, elapsed):
+        speed = float(value) / elapsed
+        return utils.scale_1024(speed, len(self.prefixes))
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data,
+        value=None,
+        total_seconds_elapsed=None,
+    ):
+        '''Updates the widget with the current SI prefixed speed.'''
         if value is None:
             value = data['value']
-        elapsed = utils.deltas_to_seconds(total_seconds_elapsed, data[
-            'total_seconds_elapsed'])
-        if (value is not None and elapsed is not None and elapsed > 2e-06 and
-            value > 2e-06):
+
+        elapsed = utils.deltas_to_seconds(
+            total_seconds_elapsed,
+            data['total_seconds_elapsed'],
+        )
+
+        if (
+            value is not None
+            and elapsed is not None
+            and elapsed > 2e-6
+            and value > 2e-6
+        ):  # =~ 0
             scaled, power = self._speed(value, elapsed)
         else:
             scaled = power = 0
+
         data['unit'] = self.unit
         if power == 0 and scaled < 0.1:
             if scaled > 0:
                 scaled = 1 / scaled
             data['scaled'] = scaled
             data['prefix'] = self.prefixes[0]
-            return FormatWidgetMixin.__call__(self, progress, data, self.
-                inverse_format)
+            return FormatWidgetMixin.__call__(
+                self,
+                progress,
+                data,
+                self.inverse_format,
+            )
         else:
             data['scaled'] = scaled
             data['prefix'] = self.prefixes[power]
@@ -499,26 +791,42 @@ class FileTransferSpeed(FormatWidgetMixin, TimeSensitiveWidgetBase):


 class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin):
-    """Widget for showing the transfer speed based on the last X samples."""
+    '''Widget for showing the transfer speed based on the last X samples.'''

     def __init__(self, **kwargs):
         FileTransferSpeed.__init__(self, **kwargs)
         SamplesMixin.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data, value=None,
-        total_seconds_elapsed=None):
-        elapsed, value = SamplesMixin.__call__(self, progress, data, delta=True
-            )
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data,
+        value=None,
+        total_seconds_elapsed=None,
+    ):
+        elapsed, value = SamplesMixin.__call__(
+            self,
+            progress,
+            data,
+            delta=True,
+        )
         return FileTransferSpeed.__call__(self, progress, data, value, elapsed)


 class AnimatedMarker(TimeSensitiveWidgetBase):
-    """An animated marker for the progress bar which defaults to appear as if
+    '''An animated marker for the progress bar which defaults to appear as if
     it were rotating.
-    """
-
-    def __init__(self, markers='|/-\\', default=None, fill='', marker_wrap=
-        None, fill_wrap=None, **kwargs):
+    '''
+
+    def __init__(
+        self,
+        markers='|/-\\',
+        default=None,
+        fill='',
+        marker_wrap=None,
+        fill_wrap=None,
+        **kwargs,
+    ):
         self.markers = markers
         self.marker_wrap = create_wrapper(marker_wrap)
         self.default = default or markers[0]
@@ -527,62 +835,105 @@ class AnimatedMarker(TimeSensitiveWidgetBase):
         WidgetBase.__init__(self, **kwargs)

     def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None):
-        """Updates the widget to show the next marker or the first marker when
+        '''Updates the widget to show the next marker or the first marker when
         finished.
-        """
+        '''
         if progress.end_time:
             return self.default
+
         marker = self.markers[data['updates'] % len(self.markers)]
         if self.marker_wrap:
             marker = self.marker_wrap.format(marker)
+
         if self.fill:
-            fill = self.fill(progress, data, width - progress.custom_len(
-                marker))
+            # Cut the last character so we can replace it with our marker
+            fill = self.fill(
+                progress,
+                data,
+                width - progress.custom_len(marker),  # type: ignore
+            )
         else:
             fill = ''
-        if isinstance(marker, int):
+
+        # Python 3 returns an int when indexing bytes
+        if isinstance(marker, int):  # pragma: no cover
             marker = bytes(marker)
             fill = fill.encode()
         else:
+            # cast fill to the same type as marker
             fill = type(marker)(fill)
-        return fill + marker
+
+        return fill + marker  # type: ignore


+# Alias for backwards compatibility
 RotatingMarker = AnimatedMarker


 class Counter(FormatWidgetMixin, WidgetBase):
-    """Displays the current count."""
+    '''Displays the current count.'''

     def __init__(self, format='%(value)d', **kwargs):
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         WidgetBase.__init__(self, format=format, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format=None
-        ):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format=None,
+    ):
         return FormatWidgetMixin.__call__(self, progress, data, format)


 class ColoredMixin:
-    _fixed_colors: ClassVar[TFixedColors] = TFixedColors(fg_none=colors.
-        yellow, bg_none=None)
-    _gradient_colors: ClassVar[TGradientColors] = TGradientColors(fg=colors
-        .gradient, bg=None)
+    _fixed_colors: ClassVar[TFixedColors] = TFixedColors(
+        fg_none=colors.yellow,
+        bg_none=None,
+    )
+    _gradient_colors: ClassVar[TGradientColors] = TGradientColors(
+        fg=colors.gradient,
+        bg=None,
+    )
+    # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict(
+    #     fg_none=colors.yellow, bg_none=None)
+    # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor |
+    #                                      None]] = dict(fg=colors.gradient,
+    #                                                    bg=None)


 class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase):
-    """Displays the current percentage as a number with a percent sign."""
+    '''Displays the current percentage as a number with a percent sign.'''

     def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs):
         self.na = na
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         WidgetBase.__init__(self, format=format, **kwargs)

+    def get_format(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format=None,
+    ):
+        # If percentage is not available, display N/A%
+        percentage = data.get('percentage', base.Undefined)
+        if not percentage and percentage != 0:
+            output = self.na
+        else:
+            output = FormatWidgetMixin.get_format(self, progress, data, format)
+
+        return self._apply_colors(output, data)
+

 class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase):
-    """Returns progress as a count of the total (e.g.: "5 of 47")."""
-    max_width_cache: dict[types.Union[str, tuple[float, float | types.Type[
-        base.UnknownLength]]], types.Optional[int]]
+    '''Returns progress as a count of the total (e.g.: "5 of 47").'''
+
+    max_width_cache: dict[
+        types.Union[str, tuple[float, float | types.Type[base.UnknownLength]]],
+        types.Optional[int],
+    ]
+
     DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s'

     def __init__(self, format=DEFAULT_FORMAT, **kwargs):
@@ -590,44 +941,80 @@ class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase):
         WidgetBase.__init__(self, format=format, **kwargs)
         self.max_width_cache = dict(default=self.max_width or 0)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format=None
-        ):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format=None,
+    ):
+        # If max_value is not available, display N/A
         if data.get('max_value'):
             data['max_value_s'] = data['max_value']
         else:
             data['max_value_s'] = 'N/A'
+
+        # if value is not available it's the zeroth iteration
         if data.get('value'):
             data['value_s'] = data['value']
         else:
             data['value_s'] = 0
-        formatted = FormatWidgetMixin.__call__(self, progress, data, format
-            =format)
+
+        formatted = FormatWidgetMixin.__call__(
+            self,
+            progress,
+            data,
+            format=format,
+        )
+
+        # Guess the maximum width from the min and max value
         key = progress.min_value, progress.max_value
-        max_width: types.Optional[int] = self.max_width_cache.get(key, self
-            .max_width)
+        max_width: types.Optional[int] = self.max_width_cache.get(
+            key,
+            self.max_width,
+        )
         if not max_width:
             temporary_data = data.copy()
             for value in key:
-                if value is None:
+                if value is None:  # pragma: no cover
                     continue
+
                 temporary_data['value'] = value
-                if (width := progress.custom_len(FormatWidgetMixin.__call__
-                    (self, progress, temporary_data, format=format))):
+                if width := progress.custom_len(  # pragma: no branch
+                    FormatWidgetMixin.__call__(
+                        self,
+                        progress,
+                        temporary_data,
+                        format=format,
+                    ),
+                ):
                     max_width = max(max_width or 0, width)
+
             self.max_width_cache[key] = max_width
-        if max_width:
+
+        # Adjust the output to have a consistent size in all cases
+        if max_width:  # pragma: no branch
             formatted = formatted.rjust(max_width)
+
         return self._apply_colors(formatted, data)


 class Bar(AutoWidthWidgetBase):
-    """A progress bar which stretches to fill the line."""
+    '''A progress bar which stretches to fill the line.'''
+
     fg: terminal.OptionalColor | None = colors.gradient
     bg: terminal.OptionalColor | None = None

-    def __init__(self, marker='#', left='|', right='|', fill=' ', fill_left
-        =True, marker_wrap=None, **kwargs):
-        """Creates a customizable progress bar.
+    def __init__(
+        self,
+        marker='#',
+        left='|',
+        right='|',
+        fill=' ',
+        fill_left=True,
+        marker_wrap=None,
+        **kwargs,
+    ):
+        '''Creates a customizable progress bar.

         The callable takes the same parameters as the `__call__` method

@@ -636,94 +1023,146 @@ class Bar(AutoWidthWidgetBase):
         right - string or callable object to use as a right border
         fill - character to use for the empty part of the progress bar
         fill_left - whether to fill from the left or the right
-        """
+        '''
         self.marker = create_marker(marker, marker_wrap)
         self.left = string_or_lambda(left)
         self.right = string_or_lambda(right)
         self.fill = string_or_lambda(fill)
         self.fill_left = fill_left
+
         AutoWidthWidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, color=True):
-        """Updates the progress bar and its subcomponents."""
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        color=True,
+    ):
+        '''Updates the progress bar and its subcomponents.'''
         left = converters.to_unicode(self.left(progress, data, width))
         right = converters.to_unicode(self.right(progress, data, width))
         width -= progress.custom_len(left) + progress.custom_len(right)
         marker = converters.to_unicode(self.marker(progress, data, width))
         fill = converters.to_unicode(self.fill(progress, data, width))
+
+        # Make sure we ignore invisible characters when filling
         width += len(marker) - progress.custom_len(marker)
+
         if self.fill_left:
             marker = marker.ljust(width, fill)
         else:
             marker = marker.rjust(width, fill)
+
         if color:
             marker = self._apply_colors(marker, data)
+
         return left + marker + right


 class ReverseBar(Bar):
-    """A bar which has a marker that goes from right to left."""
-
-    def __init__(self, marker='#', left='|', right='|', fill=' ', fill_left
-        =False, **kwargs):
-        """Creates a customizable progress bar.
+    '''A bar which has a marker that goes from right to left.'''
+
+    def __init__(
+        self,
+        marker='#',
+        left='|',
+        right='|',
+        fill=' ',
+        fill_left=False,
+        **kwargs,
+    ):
+        '''Creates a customizable progress bar.

         marker - string or updatable object to use as a marker
         left - string or updatable object to use as a left border
         right - string or updatable object to use as a right border
         fill - character to use for the empty part of the progress bar
         fill_left - whether to fill from the left or the right
-        """
-        Bar.__init__(self, marker=marker, left=left, right=right, fill=fill,
-            fill_left=fill_left, **kwargs)
+        '''
+        Bar.__init__(
+            self,
+            marker=marker,
+            left=left,
+            right=right,
+            fill=fill,
+            fill_left=fill_left,
+            **kwargs,
+        )


 class BouncingBar(Bar, TimeSensitiveWidgetBase):
-    """A bar which has a marker which bounces from side to side."""
+    '''A bar which has a marker which bounces from side to side.'''
+
     INTERVAL = datetime.timedelta(milliseconds=100)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, color=True):
-        """Updates the progress bar and its subcomponents."""
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        color=True,
+    ):
+        '''Updates the progress bar and its subcomponents.'''
         left = converters.to_unicode(self.left(progress, data, width))
         right = converters.to_unicode(self.right(progress, data, width))
         width -= progress.custom_len(left) + progress.custom_len(right)
         marker = converters.to_unicode(self.marker(progress, data, width))
+
         fill = converters.to_unicode(self.fill(progress, data, width))
-        if width:
-            value = int(data['total_seconds_elapsed'] / self.INTERVAL.
-                total_seconds())
+
+        if width:  # pragma: no branch
+            value = int(
+                data['total_seconds_elapsed'] / self.INTERVAL.total_seconds(),
+            )
+
             a = value % width
             b = width - a - 1
             if value % (width * 2) >= width:
                 a, b = b, a
+
             if self.fill_left:
                 marker = a * fill + marker + b * fill
             else:
                 marker = b * fill + marker + a * fill
+
         return left + marker + right


 class FormatCustomText(FormatWidgetMixin, WidgetBase):
-    mapping: types.Dict[str, types.Any] = dict()
+    mapping: types.Dict[str, types.Any] = dict()  # noqa: RUF012
     copy = False

-    def __init__(self, format: str, mapping: types.Optional[types.Dict[str,
-        types.Any]]=None, **kwargs):
+    def __init__(
+        self,
+        format: str,
+        mapping: types.Optional[types.Dict[str, types.Any]] = None,
+        **kwargs,
+    ):
         self.format = format
         self.mapping = mapping or self.mapping
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         WidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None):
-        return FormatWidgetMixin.__call__(self, progress, self.mapping, 
-            format or self.format)
+    def update_mapping(self, **mapping: types.Dict[str, types.Any]):
+        self.mapping.update(mapping)
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ):
+        return FormatWidgetMixin.__call__(
+            self,
+            progress,
+            self.mapping,
+            format or self.format,
+        )


 class VariableMixin:
-    """Mixin to display a custom user variable."""
+    '''Mixin to display a custom user variable.'''

     def __init__(self, name, **kwargs):
         if not isinstance(name, str):
@@ -734,7 +1173,7 @@ class VariableMixin:


 class MultiRangeBar(Bar, VariableMixin):
-    """
+    '''
     A bar with multiple sub-ranges, each represented by a different symbol.

     The various ranges are represented on a user-defined variable, formatted as
@@ -746,20 +1185,29 @@ class MultiRangeBar(Bar, VariableMixin):
             ['Symbol2', amount2],
             ...
         ]
-    """
+    '''

     def __init__(self, name, markers, **kwargs):
         VariableMixin.__init__(self, name)
         Bar.__init__(self, **kwargs)
         self.markers = [string_or_lambda(marker) for marker in markers]

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, color=True):
-        """Updates the progress bar and its subcomponents."""
+    def get_values(self, progress: ProgressBarMixinBase, data: Data):
+        return data['variables'][self.name] or []
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        color=True,
+    ):
+        '''Updates the progress bar and its subcomponents.'''
         left = converters.to_unicode(self.left(progress, data, width))
         right = converters.to_unicode(self.right(progress, data, width))
         width -= progress.custom_len(left) + progress.custom_len(right)
         values = self.get_values(progress, data)
+
         values_sum = sum(values)
         if width and values_sum:
             middle = ''
@@ -768,6 +1216,7 @@ class MultiRangeBar(Bar, VariableMixin):
             for marker, value in zip(self.markers, values):
                 marker = converters.to_unicode(marker(progress, data, width))
                 assert progress.custom_len(marker) == 1
+
                 values_accumulated += value
                 item_width = int(values_accumulated / values_sum * width)
                 item_width -= width_accumulated
@@ -777,14 +1226,51 @@ class MultiRangeBar(Bar, VariableMixin):
             fill = converters.to_unicode(self.fill(progress, data, width))
             assert progress.custom_len(fill) == 1
             middle = fill * width
+
         return left + middle + right


 class MultiProgressBar(MultiRangeBar):
-
-    def __init__(self, name, markers=' ▁▂▃▄▅▆▇█', **kwargs):
-        MultiRangeBar.__init__(self, name=name, markers=list(reversed(
-            markers)), **kwargs)
+    def __init__(
+        self,
+        name,
+        # NOTE: the markers are not whitespace even though some
+        # terminals don't show the characters correctly!
+        markers=' ▁▂▃▄▅▆▇█',
+        **kwargs,
+    ):
+        MultiRangeBar.__init__(
+            self,
+            name=name,
+            markers=list(reversed(markers)),
+            **kwargs,
+        )
+
+    def get_values(self, progress: ProgressBarMixinBase, data: Data):
+        ranges = [0.0] * len(self.markers)
+        for value in data['variables'][self.name] or []:
+            if not isinstance(value, (int, float)):
+                # Progress is (value, max)
+                progress_value, progress_max = value
+                value = float(progress_value) / float(progress_max)
+
+            if not 0 <= value <= 1:
+                raise ValueError(
+                    'Range value needs to be in the range [0..1], '
+                    f'got {value}',
+                )
+
+            range_ = value * (len(ranges) - 1)
+            pos = int(range_)
+            frac = range_ % 1
+            ranges[pos] += 1 - frac
+            if frac:
+                ranges[pos + 1] += frac
+
+        if self.fill_left:  # pragma: no branch
+            ranges = list(reversed(ranges))
+
+        return ranges


 class GranularMarkers:
@@ -797,7 +1283,7 @@ class GranularMarkers:


 class GranularBar(AutoWidthWidgetBase):
-    """A progressbar that can display progress at a sub-character granularity
+    '''A progressbar that can display progress at a sub-character granularity
     by using multiple marker characters.

     Examples of markers:
@@ -810,130 +1296,214 @@ class GranularBar(AutoWidthWidgetBase):

     The markers can be accessed through GranularMarkers. GranularMarkers.dots
     for example
-    """
+    '''

-    def __init__(self, markers=GranularMarkers.smooth, left='|', right='|',
-        **kwargs):
-        """Creates a customizable progress bar.
+    def __init__(
+        self,
+        markers=GranularMarkers.smooth,
+        left='|',
+        right='|',
+        **kwargs,
+    ):
+        '''Creates a customizable progress bar.

         markers - string of characters to use as granular progress markers. The
                   first character should represent 0% and the last 100%.
                   Ex: ` .oO`.
         left - string or callable object to use as a left border
         right - string or callable object to use as a right border
-        """
+        '''
         self.markers = markers
         self.left = string_or_lambda(left)
         self.right = string_or_lambda(right)
+
         AutoWidthWidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width: int=0
-        ):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+    ):
         left = converters.to_unicode(self.left(progress, data, width))
         right = converters.to_unicode(self.right(progress, data, width))
         width -= progress.custom_len(left) + progress.custom_len(right)
+
         max_value = progress.max_value
-        if max_value is not base.UnknownLength and max_value > 0:
-            percent = progress.value / max_value
+        # mypy doesn't get that the first part of the if statement makes sure
+        # we get the correct type
+        if (
+            max_value is not base.UnknownLength
+            and max_value > 0  # type: ignore
+        ):
+            percent = progress.value / max_value  # type: ignore
         else:
             percent = 0
+
         num_chars = percent * width
+
         marker = self.markers[-1] * int(num_chars)
-        if (marker_idx := int(num_chars % 1 * (len(self.markers) - 1))):
+
+        if marker_idx := int((num_chars % 1) * (len(self.markers) - 1)):
             marker += self.markers[marker_idx]
+
         marker = converters.to_unicode(marker)
+
+        # Make sure we ignore invisible characters when filling
         width += len(marker) - progress.custom_len(marker)
         marker = marker.ljust(width, self.markers[0])
+
         return left + marker + right


 class FormatLabelBar(FormatLabel, Bar):
-    """A bar which has a formatted label in the center."""
+    '''A bar which has a formatted label in the center.'''

     def __init__(self, format, **kwargs):
         FormatLabel.__init__(self, format, **kwargs)
         Bar.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, format: FormatString=None):
+    def __call__(  # type: ignore
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        format: FormatString = None,
+    ):
         center = FormatLabel.__call__(self, progress, data, format=format)
         bar = Bar.__call__(self, progress, data, width, color=False)
+
+        # Aligns the center of the label to the center of the bar
         center_len = progress.custom_len(center)
         center_left = int((width - center_len) / 2)
         center_right = center_left + center_len
-        return self._apply_colors(bar[:center_left], data
-            ) + self._apply_colors(center, data) + self._apply_colors(bar[
-            center_right:], data)
+
+        return (
+            self._apply_colors(
+                bar[:center_left],
+                data,
+            )
+            + self._apply_colors(
+                center,
+                data,
+            )
+            + self._apply_colors(
+                bar[center_right:],
+                data,
+            )
+        )


 class PercentageLabelBar(Percentage, FormatLabelBar):
-    """A bar which displays the current percentage in the center."""
+    '''A bar which displays the current percentage in the center.'''

+    # %3d adds an extra space that makes it look off-center
+    # %2d keeps the label somewhat consistently in-place
     def __init__(self, format='%(percentage)2d%%', na='N/A%%', **kwargs):
         Percentage.__init__(self, format, na=na, **kwargs)
         FormatLabelBar.__init__(self, format, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, format: FormatString=None):
+    def __call__(  # type: ignore
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        format: FormatString = None,
+    ):
         return super().__call__(progress, data, width, format=format)


 class Variable(FormatWidgetMixin, VariableMixin, WidgetBase):
-    """Displays a custom variable."""
-
-    def __init__(self, name, format='{name}: {formatted_value}', width=6,
-        precision=3, **kwargs):
-        """Creates a Variable associated with the given name."""
+    '''Displays a custom variable.'''
+
+    def __init__(
+        self,
+        name,
+        format='{name}: {formatted_value}',
+        width=6,
+        precision=3,
+        **kwargs,
+    ):
+        '''Creates a Variable associated with the given name.'''
         self.format = format
         self.width = width
         self.precision = precision
         VariableMixin.__init__(self, name=name)
         WidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ):
         value = data['variables'][self.name]
         context = data.copy()
         context['value'] = value
         context['name'] = self.name
         context['width'] = self.width
         context['precision'] = self.precision
+
         try:
+            # Make sure to try and cast the value first, otherwise the
+            # formatting will generate warnings/errors on newer Python releases
             value = float(value)
             fmt = '{value:{width}.{precision}}'
             context['formatted_value'] = fmt.format(**context)
         except (TypeError, ValueError):
             if value:
-                context['formatted_value'] = '{value:{width}}'.format(**context
-                    )
+                context['formatted_value'] = '{value:{width}}'.format(
+                    **context,
+                )
             else:
                 context['formatted_value'] = '-' * self.width
+
         return self.format.format(**context)


 class DynamicMessage(Variable):
-    """Kept for backwards compatibility, please use `Variable` instead."""
+    '''Kept for backwards compatibility, please use `Variable` instead.'''


 class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase):
-    """Widget which displays the current (date)time with seconds resolution."""
+    '''Widget which displays the current (date)time with seconds resolution.'''
+
     INTERVAL = datetime.timedelta(seconds=1)

-    def __init__(self, format='Current Time: %(current_time)s',
-        microseconds=False, **kwargs):
+    def __init__(
+        self,
+        format='Current Time: %(current_time)s',
+        microseconds=False,
+        **kwargs,
+    ):
         self.microseconds = microseconds
         FormatWidgetMixin.__init__(self, format=format, **kwargs)
         TimeSensitiveWidgetBase.__init__(self, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, format:
-        types.Optional[str]=None):
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        format: types.Optional[str] = None,
+    ):
         data['current_time'] = self.current_time()
         data['current_datetime'] = self.current_datetime()
+
         return FormatWidgetMixin.__call__(self, progress, data, format=format)

+    def current_datetime(self):
+        now = datetime.datetime.now()
+        if not self.microseconds:
+            now = now.replace(microsecond=0)
+
+        return now
+
+    def current_time(self):
+        return self.current_datetime().time()
+

 class JobStatusBar(Bar, VariableMixin):
-    """
+    '''
     Widget which displays the job status as markers on the bar.

     The status updates can be given either as a boolean or as a string. If it's
@@ -953,7 +1523,8 @@ class JobStatusBar(Bar, VariableMixin):
         failure_fg_color: The foreground color to use for failed jobs.
         failure_bg_color: The background color to use for failed jobs.
         failure_marker: The marker to use for failed jobs.
-    """
+    '''
+
     success_fg_color: terminal.Color | None = colors.green
     success_bg_color: terminal.Color | None = None
     success_marker: str = '█'
@@ -962,10 +1533,21 @@ class JobStatusBar(Bar, VariableMixin):
     failure_marker: str = 'X'
     job_markers: list[str]

-    def __init__(self, name: str, left='|', right='|', fill=' ', fill_left=
-        True, success_fg_color=colors.green, success_bg_color=None,
-        success_marker='█', failure_fg_color=colors.red, failure_bg_color=
-        None, failure_marker='X', **kwargs):
+    def __init__(
+        self,
+        name: str,
+        left='|',
+        right='|',
+        fill=' ',
+        fill_left=True,
+        success_fg_color=colors.green,
+        success_bg_color=None,
+        success_marker='█',
+        failure_fg_color=colors.red,
+        failure_bg_color=None,
+        failure_marker='X',
+        **kwargs,
+    ):
         VariableMixin.__init__(self, name)
         self.name = name
         self.job_markers = []
@@ -978,41 +1560,60 @@ class JobStatusBar(Bar, VariableMixin):
         self.failure_fg_color = failure_fg_color
         self.failure_bg_color = failure_bg_color
         self.failure_marker = failure_marker
-        Bar.__init__(self, left=left, right=right, fill=fill, fill_left=
-            fill_left, **kwargs)

-    def __call__(self, progress: ProgressBarMixinBase, data: Data, width:
-        int=0, color=True):
+        Bar.__init__(
+            self,
+            left=left,
+            right=right,
+            fill=fill,
+            fill_left=fill_left,
+            **kwargs,
+        )
+
+    def __call__(
+        self,
+        progress: ProgressBarMixinBase,
+        data: Data,
+        width: int = 0,
+        color=True,
+    ):
         left = converters.to_unicode(self.left(progress, data, width))
         right = converters.to_unicode(self.right(progress, data, width))
         width -= progress.custom_len(left) + progress.custom_len(right)
+
         status: str | bool | None = data['variables'].get(self.name)
+
         if width and status is not None:
             if status is True:
                 marker = self.success_marker
                 fg_color = self.success_fg_color
                 bg_color = self.success_bg_color
-            elif status is False:
+            elif status is False:  # pragma: no branch
                 marker = self.failure_marker
                 fg_color = self.failure_fg_color
                 bg_color = self.failure_bg_color
-            else:
+            else:  # pragma: no cover
                 marker = status
                 fg_color = bg_color = None
+
             marker = converters.to_unicode(marker)
-            if fg_color:
+            if fg_color:  # pragma: no branch
                 marker = fg_color.fg(marker)
-            if bg_color:
+            if bg_color:  # pragma: no cover
                 marker = bg_color.bg(marker)
+
             self.job_markers.append(marker)
             marker = ''.join(self.job_markers)
             width -= progress.custom_len(marker)
+
             fill = converters.to_unicode(self.fill(progress, data, width))
             fill = self._apply_colors(fill * width, data)
-            if self.fill_left:
+
+            if self.fill_left:  # pragma: no branch
                 marker += fill
-            else:
+            else:  # pragma: no cover
                 marker = fill + marker
         else:
             marker = ''
+
         return left + marker + right