spinners.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import math
  2. from itertools import chain
  3. from .spinner_compiler import spinner_controller
  4. from .utils import combinations, overlay_sliding_window, round_even, spinner_player, \
  5. split_options, spread_weighted, static_sliding_window
  6. from ..utils.cells import combine_cells, fix_cells, has_wide, mark_graphemes, strip_marks, to_cells
  7. def frame_spinner_factory(*frames):
  8. """Create a factory of a spinner that delivers frames in sequence, split by cycles.
  9. Supports unicode grapheme clusters and emoji chars (those that has length one but when on
  10. screen occupies two cells), as well as all other spinners.
  11. Args:
  12. frames (Union[str, Tuple[str, ...]): the frames to be displayed, split by cycles
  13. if sent only a string, it is interpreted as frames of a single char each.
  14. Returns:
  15. a styled spinner factory
  16. Examples:
  17. To define one cycle:
  18. >>> frame_spinner_factory(('cool',)) # only one frame.
  19. >>> frame_spinner_factory(('ooo', '---')) # two frames.
  20. >>> frame_spinner_factory('|/_') # three frames of one char each, same as below.
  21. >>> frame_spinner_factory(('|', '/', '_'))
  22. To define two cycles:
  23. >>> frame_spinner_factory(('super',), ('cool',)) # one frame each.
  24. >>> frame_spinner_factory(('ooo', '-'), ('vvv', '^')) # two frames each.
  25. >>> frame_spinner_factory('|/_', '▁▄█') # three frames each, same as below.
  26. >>> frame_spinner_factory(('|', '/', '_'), ('▁', '▄', '█'))
  27. Mix and match at will:
  28. >>> frame_spinner_factory(('oo', '-'), 'cool', ('it', 'is', 'alive!'))
  29. """
  30. # shortcut for single char animations.
  31. frames = (tuple(cycle) if isinstance(cycle, str) else cycle for cycle in frames)
  32. # support for unicode grapheme clusters and emoji chars.
  33. frames = tuple(tuple(to_cells(frame) for frame in cycle) for cycle in frames)
  34. @spinner_controller(natural=max(len(frame) for cycle in frames for frame in cycle))
  35. def inner_spinner_factory(actual_length=None):
  36. actual_length = actual_length or inner_spinner_factory.natural
  37. max_ratio = math.ceil(actual_length / min(len(frame) for cycle in frames
  38. for frame in cycle))
  39. def frame_data(cycle):
  40. for frame in cycle:
  41. # differently sized frames and repeat support.
  42. yield (frame * max_ratio)[:actual_length]
  43. return (frame_data(cycle) for cycle in frames)
  44. return inner_spinner_factory
  45. def scrolling_spinner_factory(chars, length=None, block=None, background=None, *,
  46. right=True, hide=True, wrap=True, overlay=False):
  47. """Create a factory of a spinner that scrolls characters from one side to
  48. the other, configurable with various constraints.
  49. Supports unicode grapheme clusters and emoji chars, those that has length one but when on
  50. screen occupies two cells.
  51. Args:
  52. chars (str): the characters to be scrolled, either together or split in blocks
  53. length (Optional[int]): the natural length that should be used in the style
  54. block (Optional[int]): if defined, split chars in blocks with this size
  55. background (Optional[str]): the pattern to be used besides or underneath the animations
  56. right (bool): the scroll direction to animate
  57. hide (bool): controls whether the animation goes through the borders or not
  58. wrap (bool): makes the animation wrap borders or stop when not hiding.
  59. overlay (bool): fixes the background in place if overlay, scrolls it otherwise
  60. Returns:
  61. a styled spinner factory
  62. """
  63. assert not (overlay and not background), 'overlay needs a background'
  64. assert not (overlay and has_wide(background)), 'unsupported overlay with grapheme background'
  65. chars, rounder = to_cells(chars), round_even if has_wide(chars) else math.ceil
  66. @spinner_controller(natural=length or len(chars))
  67. def inner_spinner_factory(actual_length=None):
  68. actual_length = actual_length or inner_spinner_factory.natural
  69. ratio = actual_length / inner_spinner_factory.natural
  70. initial, block_size = 0, rounder((block or 0) * ratio) or len(chars)
  71. if hide:
  72. gap = actual_length
  73. else:
  74. gap = max(0, actual_length - block_size)
  75. if right:
  76. initial = -block_size if block else abs(actual_length - block_size)
  77. if block:
  78. def get_block(g):
  79. return fix_cells((mark_graphemes((g,)) * block_size)[:block_size])
  80. contents = map(get_block, strip_marks(reversed(chars) if right else chars))
  81. else:
  82. contents = (chars,)
  83. window_impl = overlay_sliding_window if overlay else static_sliding_window
  84. infinite_ribbon = window_impl(to_cells(background or ' '),
  85. gap, contents, actual_length, right, initial)
  86. def frame_data():
  87. for i, fill in zip(range(gap + block_size), infinite_ribbon):
  88. if i <= size:
  89. yield fill
  90. size = gap + block_size if wrap or hide else abs(actual_length - block_size)
  91. cycles = len(tuple(strip_marks(chars))) if block else 1
  92. return (frame_data() for _ in range(cycles))
  93. return inner_spinner_factory
  94. def bouncing_spinner_factory(chars, length=None, block=None, background=None, *,
  95. right=True, hide=True, overlay=False):
  96. """Create a factory of a spinner that scrolls characters from one side to
  97. the other and bounce back, configurable with various constraints.
  98. Supports unicode grapheme clusters and emoji chars, those that has length one but when on
  99. screen occupies two cells.
  100. Args:
  101. chars (Union[str, Tuple[str, str]]): the characters to be scrolled, either
  102. together or split in blocks. Also accepts a tuple of two strings,
  103. which are used one in each direction.
  104. length (Optional[int]): the natural length that should be used in the style
  105. block (Union[int, Tuple[int, int], None]): if defined, split chars in blocks
  106. background (Optional[str]): the pattern to be used besides or underneath the animations
  107. right (bool): the scroll direction to start the animation
  108. hide (bool): controls whether the animation goes through the borders or not
  109. overlay (bool): fixes the background in place if overlay, scrolls it otherwise
  110. Returns:
  111. a styled spinner factory
  112. """
  113. chars_1, chars_2 = split_options(chars)
  114. block_1, block_2 = split_options(block)
  115. scroll_1 = scrolling_spinner_factory(chars_1, length, block_1, background, right=right,
  116. hide=hide, wrap=False, overlay=overlay)
  117. scroll_2 = scrolling_spinner_factory(chars_2, length, block_2, background, right=not right,
  118. hide=hide, wrap=False, overlay=overlay)
  119. return sequential_spinner_factory(scroll_1, scroll_2)
  120. def sequential_spinner_factory(*spinner_factories, intermix=True):
  121. """Create a factory of a spinner that combines other spinners together, playing them
  122. one at a time sequentially, either intermixing their cycles or until depletion.
  123. Args:
  124. spinner_factories (spinner): the spinners to be combined
  125. intermix (bool): intermixes the cycles if True, generating all possible combinations;
  126. runs each one until depletion otherwise.
  127. Returns:
  128. a styled spinner factory
  129. """
  130. @spinner_controller(natural=max(factory.natural for factory in spinner_factories))
  131. def inner_spinner_factory(actual_length=None):
  132. actual_length = actual_length or inner_spinner_factory.natural
  133. spinners = [factory(actual_length) for factory in spinner_factories]
  134. def frame_data(spinner):
  135. yield from spinner()
  136. if intermix:
  137. cycles = combinations(spinner.cycles for spinner in spinners)
  138. gen = ((frame_data(spinner) for spinner in spinners)
  139. for _ in range(cycles))
  140. else:
  141. gen = ((frame_data(spinner) for _ in range(spinner.cycles))
  142. for spinner in spinners)
  143. return (c for c in chain.from_iterable(gen)) # transforms the chain to a gen exp.
  144. return inner_spinner_factory
  145. def alongside_spinner_factory(*spinner_factories, pivot=None):
  146. """Create a factory of a spinner that combines other spinners together, playing them
  147. alongside simultaneously. Each one uses its own natural length, which is spread weighted
  148. to the available space.
  149. Args:
  150. spinner_factories (spinner): the spinners to be combined
  151. pivot (Optional[int]): the index of the spinner to dictate the animation cycles
  152. if None, the whole animation will be compiled into a unique cycle.
  153. Returns:
  154. a styled spinner factory
  155. """
  156. @spinner_controller(natural=sum(factory.natural for factory in spinner_factories))
  157. def inner_spinner_factory(actual_length=None, offset=0):
  158. if actual_length:
  159. lengths = spread_weighted(actual_length, [f.natural for f in spinner_factories])
  160. actual_pivot = None if pivot is None or not lengths[pivot] \
  161. else spinner_factories[pivot](lengths[pivot])
  162. spinners = [factory(length) for factory, length in
  163. zip(spinner_factories, lengths) if length]
  164. else:
  165. actual_pivot = None if pivot is None else spinner_factories[pivot]()
  166. spinners = [factory() for factory in spinner_factories]
  167. def frame_data(cycle_gen):
  168. yield from (combine_cells(*fragments) for _, *fragments in cycle_gen)
  169. frames = combinations(spinner.total_frames for spinner in spinners)
  170. spinners = [spinner_player(spinner) for spinner in spinners]
  171. [[next(player) for _ in range(i * offset)] for i, player in enumerate(spinners)]
  172. if actual_pivot is None:
  173. breaker, cycles = lambda: range(frames), 1
  174. else:
  175. breaker, cycles = lambda: actual_pivot(), \
  176. frames // actual_pivot.total_frames * actual_pivot.cycles
  177. return (frame_data(zip(breaker(), *spinners)) for _ in range(cycles))
  178. return inner_spinner_factory
  179. def delayed_spinner_factory(spinner_factory, copies, offset=1, *, dynamic=True):
  180. """Create a factory of a spinner that combines itself several times alongside,
  181. with an increasing iteration offset on each one.
  182. Args:
  183. spinner_factory (spinner): the source spinner
  184. copies (int): the number of copies
  185. offset (int): the offset to be applied incrementally to each copy
  186. dynamic (bool): dynamically changes the number of copies based on available space
  187. Returns:
  188. a styled spinner factory
  189. """
  190. if not dynamic:
  191. factories = (spinner_factory,) * copies
  192. return alongside_spinner_factory(*factories, pivot=0).op(offset=offset)
  193. @spinner_controller(natural=spinner_factory.natural * copies, skip_compiler=True)
  194. def inner_spinner_factory(actual_length=None):
  195. n = math.ceil(actual_length / spinner_factory.natural) if actual_length else copies
  196. return delayed_spinner_factory(spinner_factory, n, offset, dynamic=False)(actual_length)
  197. return inner_spinner_factory