Source code for progressbar.terminal.base

from __future__ import annotations

import abc
import collections.abc
import colorsys
import enum
import typing
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

from .. import (
    base as pbase,
    env,
)

# Re-exported for backwards compatibility (previously consumed by the removed
# ``_CPR`` cursor-position helper; guarded by the API snapshot). The redundant
# alias marks the re-export as intentional so it is not stripped as unused.
from .os_specific import getch as getch

ESC = '\x1b'


[docs] class CSI: _code: str _template = ESC + '[{args}{code}' def __init__(self, code: str, *default_args: typing.Any) -> None: self._code = code self._default_args = default_args def __call__(self, *args: typing.Any) -> str: return self._template.format( args=';'.join(map(str, args or self._default_args)), code=self._code, ) def __str__(self) -> str: return self()
[docs] class CSINoArg(CSI): def __call__( # pyright: ignore[reportIncompatibleMethodOverride] self, ) -> str: return super().__call__()
#: Cursor Position [row;column] (default = [1,1]) CUP: CSI = CSI('H', 1, 1) #: Cursor Up Ps Times (default = 1) (CUU) UP: CSI = CSI('A', 1) #: Cursor Down Ps Times (default = 1) (CUD) DOWN: CSI = CSI('B', 1) #: Cursor Forward Ps Times (default = 1) (CUF) RIGHT: CSI = CSI('C', 1) #: Cursor Backward Ps Times (default = 1) (CUB) LEFT: CSI = CSI('D', 1) #: Cursor Next Line Ps Times (default = 1) (CNL) #: Same as Cursor Down Ps Times NEXT_LINE: CSI = CSI('E', 1) #: Cursor Preceding Line Ps Times (default = 1) (CPL) #: Same as Cursor Up Ps Times PREVIOUS_LINE: CSI = CSI('F', 1) #: Cursor Character Absolute [column] (default = [row,1]) (CHA) COLUMN: CSI = CSI('G', 1) #: Erase in Display (ED) CLEAR_SCREEN: CSI = CSI('J', 0) #: Erase till end of screen CLEAR_SCREEN_TILL_END: CSINoArg = CSINoArg('0J') #: Erase till start of screen CLEAR_SCREEN_TILL_START: CSINoArg = CSINoArg('1J') #: Erase whole screen CLEAR_SCREEN_ALL: CSINoArg = CSINoArg('2J') #: Erase whole screen and history CLEAR_SCREEN_ALL_AND_HISTORY: CSINoArg = CSINoArg('3J') #: Erase in Line (EL) CLEAR_LINE_ALL: CSI = CSI('K') #: Erase in Line from Cursor to End of Line (default) CLEAR_LINE_RIGHT: CSINoArg = CSINoArg('0K') #: Erase in Line from Cursor to Beginning of Line CLEAR_LINE_LEFT: CSINoArg = CSINoArg('1K') #: Erase Line containing Cursor CLEAR_LINE: CSINoArg = CSINoArg('2K') #: Scroll up Ps lines (default = 1) (SU) #: Scroll down Ps lines (default = 1) (SD) SCROLL_UP: CSI = CSI('S') SCROLL_DOWN: CSI = CSI('T') #: Save Cursor Position (SCP) SAVE_CURSOR: CSINoArg = CSINoArg('s') #: Restore Cursor Position (RCP) RESTORE_CURSOR: CSINoArg = CSINoArg('u') #: Cursor Visibility (DECTCEM) HIDE_CURSOR: CSINoArg = CSINoArg('?25l') SHOW_CURSOR: CSINoArg = CSINoArg('?25h') # # 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
[docs] def clear_line(n: int): return UP(n) + CLEAR_LINE_ALL() + DOWN(n)
[docs] class WindowsColors(enum.Enum): BLACK = 0, 0, 0 BLUE = 0, 0, 128 GREEN = 0, 128, 0 CYAN = 0, 128, 128 RED = 128, 0, 0 MAGENTA = 128, 0, 128 YELLOW = 128, 128, 0 GREY = 192, 192, 192 INTENSE_BLACK = 128, 128, 128 INTENSE_BLUE = 0, 0, 255 INTENSE_GREEN = 0, 255, 0 INTENSE_CYAN = 0, 255, 255 INTENSE_RED = 255, 0, 0 INTENSE_MAGENTA = 255, 0, 255 INTENSE_YELLOW = 255, 255, 0 INTENSE_WHITE = 255, 255, 255
[docs] @staticmethod def from_rgb(rgb: tuple[int, int, int]) -> WindowsColors: """ Find the closest WindowsColors to the given RGB color. >>> WindowsColors.from_rgb((0, 0, 0)) <WindowsColors.BLACK: (0, 0, 0)> >>> WindowsColors.from_rgb((255, 255, 255)) <WindowsColors.INTENSE_WHITE: (255, 255, 255)> >>> WindowsColors.from_rgb((0, 255, 0)) <WindowsColors.INTENSE_GREEN: (0, 255, 0)> >>> WindowsColors.from_rgb((45, 45, 45)) <WindowsColors.BLACK: (0, 0, 0)> >>> WindowsColors.from_rgb((128, 0, 128)) <WindowsColors.MAGENTA: (128, 0, 128)> """ def color_distance( rgb1: tuple[int, int, int], rgb2: tuple[int, int, int], ): return sum( (c1 - c2) ** 2 for c1, c2 in zip(rgb1, rgb2, strict=False) ) return min( WindowsColors, key=lambda color: color_distance(color.value, rgb), )
[docs] 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',) def __init__(self, color: Color) -> None: self.color = color def __call__(self, text: str) -> str: 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))
[docs] class RGB(typing.NamedTuple): """ Red, Green, Blue color. """ red: int green: int blue: int def __str__(self): return self.rgb @property def rgb(self) -> str: return f'rgb({self.red}, {self.green}, {self.blue})' @property def hex(self) -> str: return f'#{self.red:02x}{self.green:02x}{self.blue:02x}' @property def to_ansi_16(self) -> int: # Threshold each channel at half intensity so a mid-range channel # sets its bit. ``int(c / 255)`` was only ever 1 at exactly 255, which # collapsed almost every colour (e.g. maroon 128,0,0) to black. red = int(self.red >= 128) green = int(self.green >= 128) blue = int(self.blue >= 128) return (blue << 2) | (green << 1) | red @property def to_ansi_256(self) -> int: 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. """ return WindowsColors.from_rgb((self.red, self.green, self.blue))
[docs] 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), )
[docs] class HSL(typing.NamedTuple): """ Hue, Saturation, Lightness color. Hue is a value between 0 and 360, saturation and lightness are between 0(%) and 100(%). """ hue: float saturation: float lightness: float
[docs] @classmethod def from_rgb(cls, rgb: RGB) -> HSL: """ Convert a 0-255 RGB color to a 0-255 HLS color. """ 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), )
[docs] def interpolate(self, end: HSL, step: float) -> HSL: return HSL( self.hue + (end.hue - self.hue) * step, self.saturation + (end.saturation - self.saturation) * step, self.lightness + (end.lightness - self.lightness) * step, )
[docs] class ColorBase(abc.ABC): """ Deprecated, `typing.NamedTuple` does not allow for multiple inheritance so this class cannot be used with type hints. """
[docs] def get_color(self, value: float) -> Color: raise NotImplementedError()
[docs] class Color(typing.NamedTuple): """ Color base class. This class contains the colors in RGB (Red, Green, Blue), HSL (Hue, Lightness, Saturation) and Xterm (8-bit) formats. It also contains the color name. 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. """ rgb: RGB hls: HSL name: str | None xterm: int | None def __call__(self, value: str) -> str: return self.fg(value) @property def fg(self) -> SGRColor | WindowsColor: if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: return WindowsColor(self) else: return SGRColor(self, 38, 39) @property def bg(self) -> DummyColor | SGRColor: if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: return DummyColor() else: return SGRColor(self, 48, 49) @property def underline(self) -> DummyColor | SGRColor: if env.COLOR_SUPPORT is env.ColorSupport.WINDOWS: return DummyColor() else: return SGRColor(self, 58, 59) @property def ansi(self) -> str | None: if ( env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR ): # pragma: no branch return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' # A true 16-colour terminal must not be handed a 256-colour index, # so translate through to_ansi_16 there. Everywhere else prefer the # registered xterm index (``is not None`` so index 0/Black counts): # rendering an SGR at all means the caller decided colours are # wanted (e.g. forced via ``enable_colors``), even when the global # detection reported no support. if env.COLOR_SUPPORT is env.ColorSupport.XTERM: color = self.rgb.to_ansi_16 elif self.xterm is not None: color = self.xterm elif ( env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 ): # pragma: no branch color = self.rgb.to_ansi_256 else: # pragma: no branch return None return f'5;{color}'
[docs] 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) -> str: if self.name: return self.name else: return str(self.rgb) def __repr__(self) -> str: return f'{self.__class__.__name__}({self.name!r})' def __hash__(self) -> int: return hash(self.rgb)
[docs] class Colors: by_name: ClassVar[defaultdict[str, list[Color]]] = defaultdict(list) by_lowername: ClassVar[defaultdict[str, list[Color]]] = defaultdict(list) by_hex: ClassVar[defaultdict[str, list[Color]]] = defaultdict(list) by_rgb: ClassVar[defaultdict[RGB, list[Color]]] = defaultdict(list) by_hls: ClassVar[defaultdict[HSL, list[Color]]] = defaultdict(list) by_xterm: ClassVar[dict[int, Color]] = dict()
[docs] @classmethod def register( cls, rgb: RGB, hls: HSL | None = None, name: str | None = None, xterm: int | None = None, ) -> Color: if hls is None: hls = HSL.from_rgb(rgb) color = Color(rgb, hls, name, xterm) if name: cls.by_name[name].append(color) cls.by_lowername[name.lower()].append(color) 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
[docs] @classmethod def interpolate(cls, color_a: Color, color_b: Color, step: float) -> Color: return color_a.interpolate(color_b, step)
[docs] class ColorGradient: interpolate: collections.abc.Callable[[Color, Color, float], Color] | None colors: tuple[Color, ...] def __init__( self, *colors: Color, interpolate: ( collections.abc.Callable[[Color, Color, float], Color] | None ) = Colors.interpolate, ) -> None: assert colors self.colors = colors self.interpolate = interpolate def __call__(self, value: float) -> Color: return self.get_color(value)
[docs] 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 = Color | ColorGradient | None
[docs] def get_color(value: float, color: OptionalColor) -> Color | None: if isinstance(color, ColorGradient): color = color(value) return color
[docs] 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: typing.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. """ 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
[docs] class DummyColor: def __call__(self, text: str): return text def __repr__(self) -> str: return 'DummyColor()'
[docs] class SGR(CSI): _start_code: int _end_code: int _code = 'm' __slots__ = '_end_code', '_start_code' def __init__(self, start_code: int, end_code: int) -> None: 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__( # pyright: ignore[reportIncompatibleMethodOverride] self, text: str, *args: typing.Any, ) -> str: return self._start_template + text + self._end_template
[docs] class SGRColor(SGR): __slots__ = '_color', '_end_code', '_start_code' def __init__(self, color: Color, start_code: int, end_code: int) -> None: self._color = color super().__init__(start_code, end_code) def __call__( # pyright: ignore[reportIncompatibleMethodOverride] self, text: str, *args: typing.Any, ) -> str: if self._color.ansi is None: # No usable color representation for this terminal (e.g. color # support is NONE): leave the text unstyled instead of emitting # a malformed escape code containing the literal string 'None'. return text return super().__call__(text, *args) @property def _start_template(self): return CSI.__call__(self, self._start_code, self._color.ansi)
encircled: SGR = SGR(52, 54) framed: SGR = SGR(51, 54) overline: SGR = SGR(53, 55) bold: SGR = SGR(1, 22) gothic: SGR = SGR(20, 10) italic: SGR = SGR(3, 23) strike_through: SGR = SGR(9, 29) fast_blink: SGR = SGR(6, 25) slow_blink: SGR = SGR(5, 25) underline: SGR = SGR(4, 24) double_underline: SGR = SGR(21, 24) faint: SGR = SGR(2, 22) inverse: SGR = SGR(7, 27)