bars.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import math
  2. import time
  3. from about_time import about_time
  4. from .utils import bordered, extract_fill_graphemes, fix_signature, spinner_player
  5. from ..utils import terminal
  6. from ..utils.cells import VS_15, combine_cells, fix_cells, has_wide, is_wide, join_cells, \
  7. mark_graphemes, split_graphemes, strip_marks, to_cells
  8. from ..utils.colors import BLUE, BLUE_BOLD, CYAN, DIM, GREEN, ORANGE, ORANGE_BOLD, RED, YELLOW_BOLD
  9. def bar_factory(chars=None, *, tip=None, background=None, borders=None, errors=None):
  10. """Create a factory of a bar with the given styling parameters.
  11. Supports unicode grapheme clusters and emoji chars (those that has length one but when on
  12. screen occupies two cells).
  13. Now supports transparent fills! Just send a tip, and leave `chars` as None.
  14. Also tips are now considered for the 100%, which means it smoothly enters and exits the
  15. frame to get to 100%!! The effect is super cool, use a multi-char tip to see.
  16. Args:
  17. chars (Optional[str]): the sequence of increasing glyphs to fill the bar
  18. can be None for a transparent fill, unless tip is also None.
  19. tip (Optional[str): the tip in front of the bar
  20. can be None, unless chars is also None.
  21. background (Optional[str]): the pattern to be used underneath the bar
  22. borders (Optional[Union[str, Tuple[str, str]]): the pattern or patterns to be used
  23. before and after the bar
  24. errors (Optional[Union[str, Tuple[str, str]]): the pattern or patterns to be used
  25. when an underflow or overflow occurs
  26. Returns:
  27. a styled bar factory
  28. """
  29. @bar_controller
  30. def inner_bar_factory(length, spinner_factory=None):
  31. if chars:
  32. if is_wide(chars[-1]): # previous chars can be anything.
  33. def fill_style(complete, filling): # wide chars fill.
  34. odd = bool(complete % 2)
  35. fill = (None,) if odd != bool(filling) else () # odd XOR filling.
  36. fill += (chars[-1], None) * int(complete / 2) # already marked wide chars.
  37. if filling and odd:
  38. fill += mark_graphemes((chars[filling - 1],))
  39. return fill
  40. else: # previous chars cannot be wide.
  41. def fill_style(complete, filling): # narrow chars fill.
  42. fill = (chars[-1],) * complete # unneeded marks here.
  43. if filling:
  44. fill += (chars[filling - 1],) # no widies here.
  45. return fill
  46. else:
  47. def fill_style(complete, filling): # invisible fill.
  48. return fix_cells(padding[:complete + bool(filling)])
  49. def running(fill):
  50. return None, (fix_cells(padding[len(fill) + len_tip:]),) # this is a 1-tuple.
  51. def ended(fill):
  52. border = None if len(fill) + len(underflow) <= length else underflow
  53. texts = *(() if border else (underflow,)), blanks
  54. return border, texts
  55. @bordered(borders, '||')
  56. def draw_known(apply_state, percent):
  57. virtual_fill = round(virtual_length * max(0., min(1., percent)))
  58. fill = fill_style(*divmod(virtual_fill, num_graphemes))
  59. border, texts = apply_state(fill)
  60. border = overflow if percent > 1. else None if percent == 1. else border
  61. return fix_cells(combine_cells(fill, tip, *texts)[len_tip:length + len_tip]), border
  62. if spinner_factory:
  63. @bordered(borders, '||')
  64. def draw_unknown(_percent=None):
  65. return next(player), None
  66. player = spinner_player(spinner_factory(length))
  67. else:
  68. draw_unknown = None
  69. padding = (' ',) * len_tip + background * math.ceil((length + len_tip) / len(background))
  70. virtual_length, blanks = num_graphemes * (length + len_tip), (' ',) * length
  71. return draw_known, running, ended, draw_unknown
  72. assert chars or tip, 'tip is mandatory for transparent bars'
  73. assert not (chars and not is_wide(chars[-1]) and has_wide(chars)), \
  74. 'cannot use grapheme with a narrow last char'
  75. chars = split_graphemes(chars or '') # the only one not yet marked.
  76. tip, background = (to_cells(x) for x in (tip, background or ' '))
  77. underflow, overflow = extract_fill_graphemes(errors, (f'⚠{VS_15}', f'✗{VS_15}'))
  78. num_graphemes, len_tip = len(chars) or 1, len(tip)
  79. return inner_bar_factory
  80. def bar_controller(inner_bar_factory):
  81. def bar_assembler_factory(length, spinner_factory=None):
  82. """Assembles this bar into an actual bar renderer.
  83. Args:
  84. length (int): the bar rendition length (excluding the borders)
  85. spinner_factory (Optional[spinner_factory]): enable this bar to act in unknown mode
  86. Returns:
  87. a bar renderer
  88. """
  89. with about_time() as t_compile:
  90. draw_known, running, ended, draw_unknown = inner_bar_factory(length, spinner_factory)
  91. def draw(percent):
  92. return draw_known(running, percent)
  93. def draw_end(percent):
  94. return draw_known(ended, percent)
  95. def bar_check(*args, **kwargs): # pragma: no cover
  96. return check(draw, t_compile, *args, **kwargs)
  97. draw.__dict__.update(
  98. end=draw_end, unknown=draw_unknown,
  99. check=fix_signature(bar_check, check, 2),
  100. )
  101. if draw_unknown:
  102. def draw_unknown_end(_percent=None):
  103. return draw_end(1.)
  104. draw_unknown.end = draw_unknown_end
  105. return draw
  106. def compile_and_check(*args, **kwargs): # pragma: no cover
  107. """Compile this bar factory at some length, and..."""
  108. # since a bar does not have a natural length, I have to choose one...
  109. bar_assembler_factory(40).check(*args, **kwargs) # noqa
  110. bar_assembler_factory.__dict__.update(
  111. check=fix_signature(compile_and_check, check, 2),
  112. )
  113. return bar_assembler_factory
  114. def check(bar, t_compile, verbosity=0, *, steps=20): # noqa # pragma: no cover
  115. """Check the data, codepoints, and even the animation of this bar.
  116. Args:
  117. verbosity (int): change the verbosity level
  118. 0 for brief data only (default)
  119. / \\
  120. / 3 to include animation
  121. / \\
  122. 1 to unfold bar data ---------- 4 to unfold bar data
  123. | |
  124. 2 to reveal codepoints -------- 5 to reveal codepoints
  125. steps (int): number of steps to display the bar progress
  126. """
  127. verbosity = max(0, min(5, verbosity or 0))
  128. if verbosity in (1, 2, 4, 5):
  129. render_data(bar, verbosity in (2, 5), steps)
  130. else:
  131. spec_data(bar) # spec_data here displays only brief data, shown only if not full.
  132. duration = t_compile.duration_human
  133. print(f'\nBar style compiled in: {GREEN(duration)}')
  134. print(f'(call {HELP_MSG[verbosity]})')
  135. if verbosity in (3, 4, 5):
  136. animate(bar)
  137. def __check(p):
  138. return f'{BLUE(f".{check.__name__}(")}{BLUE_BOLD(p)}{BLUE(")")}'
  139. SECTION = ORANGE_BOLD
  140. HELP_MSG = {
  141. 0: f'{__check(1)} to unfold bar data, or {__check(3)} to include animation',
  142. 1: f'{__check(2)} to reveal codepoints, or {__check(4)} to include animation,'
  143. f' or {__check(0)} to fold up bar data',
  144. 2: f'{__check(5)} to include animation, or {__check(1)} to hide codepoints',
  145. 3: f'{__check(4)} to unfold bar data, or {__check(0)} to omit animation',
  146. 4: f'{__check(5)} to reveal codepoints, or {__check(1)} to omit animation,'
  147. f' or {__check(3)} to fold up bar data',
  148. 5: f'{__check(2)} to omit animation, or {__check(4)} to hide codepoints',
  149. }
  150. def spec_data(bar): # pragma: no cover
  151. def info(field, p, b):
  152. return f'{YELLOW_BOLD(field, "<11")}: {" ".join(bar_repr(b, p)[1:])}'
  153. print(f'\n{SECTION("Brief bar data")}')
  154. print('\n'.join(info(n, p, bar) for n, p in (
  155. ('starting', 0.), ('in progress', .5), ('completed', 1.), ('overflow', 1.2)
  156. )))
  157. print(info('underflow', .5, bar.end))
  158. def format_codepoints(frame): # pragma: no cover
  159. codes = '|'.join((ORANGE if is_wide(g) else BLUE)(
  160. ' '.join(hex(ord(c)).replace('0x', '') for c in g)) for g in frame)
  161. return f" -> {RED(sum(len(fragment) for fragment in frame))}:[{codes}]"
  162. def render_data(bar, show_codepoints, steps): # pragma: no cover
  163. print(f'\n{SECTION("Full bar data")}', end='')
  164. codepoints = format_codepoints if show_codepoints else lambda _: ''
  165. for name, b in ('in progress', bar), ('completed', bar.end):
  166. print(f'\n{name}')
  167. for p in (x / steps for x in range(steps + 2)):
  168. frame, joined, perc = bar_repr(b, p)
  169. print(joined, perc, codepoints(frame))
  170. def bar_repr(bar, p): # pragma: no cover
  171. frame = tuple(strip_marks(bar(p)))
  172. return frame, ''.join(frame), DIM(f'{p:6.1%}')
  173. def animate(bar): # pragma: no cover
  174. print(f'\n{SECTION("Animation")}')
  175. from ..styles.exhibit import exhibit_bar
  176. bar_gen = exhibit_bar(bar, 15)
  177. term = terminal.get_term()
  178. term.hide_cursor()
  179. try:
  180. while True:
  181. rendition, percent = next(bar_gen)
  182. print(f'\r{join_cells(rendition)}', CYAN(max(0., percent), "6.1%"))
  183. print(DIM('(press CTRL+C to stop)'), end='')
  184. term.clear_end_line()
  185. time.sleep(1 / 15)
  186. term.cursor_up_1()
  187. except KeyboardInterrupt:
  188. pass
  189. finally:
  190. term.show_cursor()