2 # -*- coding: utf-8 -*-
4 """Utilities for dealing with "text"."""
11 from collections import defaultdict
12 from dataclasses import dataclass
13 from typing import Dict, Generator, List, Literal, Optional, Tuple
15 from ansi import fg, reset
17 logger = logging.getLogger(__file__)
28 def get_console_rows_columns() -> RowsColumns:
29 """Returns the number of rows/columns on the current console."""
31 from exec_utils import cmd
38 except Exception as e:
40 raise Exception('Can\'t determine console size?!') from e
41 return RowsColumns(int(rows), int(columns))
49 fgcolor=fg("school bus yellow"),
54 """Draws a progress graph."""
56 percent = current / total
57 ret = "\r" if redraw else "\n"
66 print(bar, end=ret, flush=True, file=sys.stderr)
74 fgcolor=fg("school bus yellow"),
79 """Returns a string containing a bar graph.
81 >>> bar_graph(0.5, fgcolor='', reset_seq='')
82 '[███████████████████████████████████ ] 50.0%'
86 if percentage < 0.0 or percentage > 1.0:
87 raise ValueError(percentage)
89 text = f"{percentage*100.0:2.1f}%"
92 whole_width = math.floor(percentage * width)
93 if whole_width == width:
96 elif whole_width == 0 and percentage > 0.0:
99 remainder_width = (percentage * width) % 1
100 part_width = math.floor(remainder_width * 8)
101 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
107 + " " * (width - whole_width - 1)
115 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
117 Makes a "sparkline" little inline histogram graph. Auto scales.
119 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
122 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
123 (73, 104, '█▇▆▆▃▂▄▁')
126 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
129 min_num, max_num = min(numbers), max(numbers)
130 span = max_num - min_num
132 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
134 return min_num, max_num, sline
137 def distribute_strings(
144 Distributes strings into a line for justified text.
146 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
150 ret = ' ' + ' '.join(strings) + ' '
151 assert len(ret) < width
153 while len(ret) < width:
154 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
158 ret = before + padding + after
165 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
169 >>> justify_string_by_chunk("This is a test", 40)
171 >>> justify_string_by_chunk("This is a test", 20)
175 assert len(string) <= width
177 first, *rest, last = string.split()
178 w = width - (len(first) + len(last))
179 ret = first + distribute_strings(rest, width=w, padding=padding) + last
184 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
188 >>> justify_string('This is another test', width=40, alignment='c')
189 ' This is another test '
190 >>> justify_string('This is another test', width=40, alignment='l')
191 'This is another test '
192 >>> justify_string('This is another test', width=40, alignment='r')
193 ' This is another test'
194 >>> justify_string('This is another test', width=40, alignment='j')
195 'This is another test'
198 alignment = alignment[0]
200 while len(string) < width:
203 elif alignment == "r":
204 string = padding + string
205 elif alignment == "j":
206 return justify_string_by_chunk(string, width=width, padding=padding)
207 elif alignment == "c":
208 if len(string) % 2 == 0:
211 string = padding + string
217 def justify_text(text: str, *, width: int = 80, alignment: str = "c") -> str:
221 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
222 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
223 'This is a test of the emergency\\nbroadcast system. This is only a test.'
228 for word in text.split():
229 if len(line) + len(word) > width:
231 line = justify_string(line, width=width, alignment=alignment)
232 retval = retval + "\n" + line
234 line = line + " " + word
236 retval += "\n" + line[1:]
240 def generate_padded_columns(text: List[str]) -> Generator:
241 max_width: Dict[int, int] = defaultdict(int)
243 for pos, word in enumerate(line.split()):
244 max_width[pos] = max(max_width[pos], len(word))
248 for pos, word in enumerate(line.split()):
249 width = max_width[pos]
250 word = justify_string(word, width=width, alignment='l')
255 def wrap_string(text: str, n: int) -> str:
256 chunks = text.split()
260 if width + len(chunk) > n:
264 width += len(chunk) + 1
268 class Indenter(contextlib.AbstractContextManager):
270 with Indenter(pad_count = 8) as i:
282 pad_prefix: Optional[str] = None,
287 if pad_prefix is not None:
288 self.pad_prefix = pad_prefix
291 self.padding = pad_char * pad_count
297 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
303 def print(self, *arg, **kwargs):
306 text = string_utils.sprintf(*arg, **kwargs)
307 print(self.pad_prefix + self.padding * self.level + text, end='')
310 def header(title: str, *, width: int = 80, color: str = ''):
312 Returns a nice header line with a title.
314 >>> header('title', width=60, color='')
315 '----[ title ]-----------------------------------------------'
322 right = (w - 4) * '-'
323 if color != '' and color is not None:
327 return f'{left}[ {color}{title}{r} ]{right}'
333 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
342 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
343 if title is not None:
348 + justify_string(title, width=w, alignment='c')
354 ret += color + '│' + ' ' * w + '│' + rset + '\n'
356 for line in justify_text(text, width=w - 2, alignment='l').split('\n'):
359 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
360 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
365 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
367 """Draws a box with nice rounded corners.
369 >>> print_box('Title', 'This is text', width=30)
370 ╭────────────────────────────╮
374 ╰────────────────────────────╯
376 >>> print_box(None, 'OK', width=6)
382 print(box(title, text, width=width, color=color), end='')
385 if __name__ == '__main__':