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
16 from ansi import fg, reset
18 logger = logging.getLogger(__file__)
29 def get_console_rows_columns() -> RowsColumns:
30 """Returns the number of rows/columns on the current console."""
32 from exec_utils import cmd
39 except Exception as e:
41 raise Exception('Can\'t determine console size?!') from e
42 return RowsColumns(int(rows), int(columns))
50 fgcolor=fg("school bus yellow"),
55 """Draws a progress graph."""
57 percent = current / total
58 ret = "\r" if redraw else "\n"
67 print(bar, end=ret, flush=True, file=sys.stderr)
75 fgcolor=fg("school bus yellow"),
80 """Returns a string containing a bar graph.
82 >>> bar_graph(0.5, fgcolor='', reset_seq='')
83 '[███████████████████████████████████ ] 50.0%'
87 if percentage < 0.0 or percentage > 1.0:
88 raise ValueError(percentage)
90 text = f"{percentage*100.0:2.1f}%"
93 whole_width = math.floor(percentage * width)
94 if whole_width == width:
97 elif whole_width == 0 and percentage > 0.0:
100 remainder_width = (percentage * width) % 1
101 part_width = math.floor(remainder_width * 8)
102 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
108 + " " * (width - whole_width - 1)
116 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
118 Makes a "sparkline" little inline histogram graph. Auto scales.
120 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
123 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
124 (73, 104, '█▇▆▆▃▂▄▁')
127 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
130 min_num, max_num = min(numbers), max(numbers)
131 span = max_num - min_num
133 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
135 return min_num, max_num, sline
138 def distribute_strings(
145 Distributes strings into a line for justified text.
147 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
151 ret = ' ' + ' '.join(strings) + ' '
152 assert len(string_utils.strip_ansi_sequences(ret)) < width
154 while len(string_utils.strip_ansi_sequences(ret)) < width:
155 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
159 ret = before + padding + after
166 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
170 >>> justify_string_by_chunk("This is a test", 40)
172 >>> justify_string_by_chunk("This is a test", 20)
176 assert len(string_utils.strip_ansi_sequences(string)) <= width
178 first, *rest, last = string.split()
180 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
182 ret = first + distribute_strings(rest, width=w, padding=padding) + last
187 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
191 >>> justify_string('This is another test', width=40, alignment='c')
192 ' This is another test '
193 >>> justify_string('This is another test', width=40, alignment='l')
194 'This is another test '
195 >>> justify_string('This is another test', width=40, alignment='r')
196 ' This is another test'
197 >>> justify_string('This is another test', width=40, alignment='j')
198 'This is another test'
201 alignment = alignment[0]
203 while len(string_utils.strip_ansi_sequences(string)) < width:
206 elif alignment == "r":
207 string = padding + string
208 elif alignment == "j":
209 return justify_string_by_chunk(string, width=width, padding=padding)
210 elif alignment == "c":
211 if len(string) % 2 == 0:
214 string = padding + string
220 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
222 Justifies text optionally with initial indentation.
224 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
225 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
226 'This is a test of the emergency\\nbroadcast system. This is only a test.'
232 indent += ' ' * indent_by
235 for word in text.split():
237 len(string_utils.strip_ansi_sequences(line))
238 + len(string_utils.strip_ansi_sequences(word))
241 line = justify_string(line, width=width, alignment=alignment)
242 retval = retval + '\n' + line
244 line = line + ' ' + word
245 if len(string_utils.strip_ansi_sequences(line)) > 0:
247 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
249 retval += "\n" + line[1:]
253 def generate_padded_columns(text: List[str]) -> Generator:
254 max_width: Dict[int, int] = defaultdict(int)
256 for pos, word in enumerate(line.split()):
257 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
261 for pos, word in enumerate(line.split()):
262 width = max_width[pos]
263 word = justify_string(word, width=width, alignment='l')
268 def wrap_string(text: str, n: int) -> str:
269 chunks = text.split()
273 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
277 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
281 class Indenter(contextlib.AbstractContextManager):
283 with Indenter(pad_count = 8) as i:
295 pad_prefix: Optional[str] = None,
300 if pad_prefix is not None:
301 self.pad_prefix = pad_prefix
304 self.padding = pad_char * pad_count
310 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
316 def print(self, *arg, **kwargs):
317 text = string_utils.sprintf(*arg, **kwargs)
318 print(self.pad_prefix + self.padding * self.level + text, end='')
321 def header(title: str, *, width: int = 80, color: str = ''):
323 Returns a nice header line with a title.
325 >>> header('title', width=60, color='')
326 '----[ title ]-----------------------------------------------'
330 w -= len(string_utils.strip_ansi_sequences(title)) + 4
333 right = (w - 4) * '-'
334 if color != '' and color is not None:
338 return f'{left}[ {color}{title}{r} ]{right}'
344 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
353 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
354 if title is not None:
359 + justify_string(title, width=w, alignment='c')
365 ret += color + '│' + ' ' * w + '│' + rset + '\n'
367 for line in justify_text(text, width=w - 2, alignment='l').split('\n'):
368 tw = len(string_utils.strip_ansi_sequences(line))
370 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
371 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
376 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
378 """Draws a box with nice rounded corners.
380 >>> print_box('Title', 'This is text', width=30)
381 ╭────────────────────────────╮
385 ╰────────────────────────────╯
387 >>> print_box(None, 'OK', width=6)
393 print(box(title, text, width=width, color=color), end='')
396 if __name__ == '__main__':