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.'
227 for word in text.split():
228 if len(line) + len(word) > width:
230 line = justify_string(line, width=width, alignment=alignment)
231 retval = retval + "\n" + line
233 line = line + " " + word
235 retval += "\n" + line[1:]
239 def generate_padded_columns(text: List[str]) -> Generator:
240 max_width: Dict[int, int] = defaultdict(int)
242 for pos, word in enumerate(line.split()):
243 max_width[pos] = max(max_width[pos], len(word))
247 for pos, word in enumerate(line.split()):
248 width = max_width[pos]
249 word = justify_string(word, width=width, alignment='l')
254 def wrap_string(text: str, n: int) -> str:
255 chunks = text.split()
259 if width + len(chunk) > n:
263 width += len(chunk) + 1
267 class Indenter(contextlib.AbstractContextManager):
269 with Indenter(pad_count = 8) as i:
280 pad_prefix: Optional[str] = None,
285 if pad_prefix is not None:
286 self.pad_prefix = pad_prefix
289 self.padding = pad_char * pad_count
295 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
301 def print(self, *arg, **kwargs):
304 text = string_utils.sprintf(*arg, **kwargs)
305 print(self.pad_prefix + self.padding * self.level + text, end='')
308 def header(title: str, *, width: int = 80, color: str = ''):
310 Returns a nice header line with a title.
312 >>> header('title', width=60, color='')
313 '----[ title ]-----------------------------------------------'
320 right = (w - 4) * '-'
321 if color != '' and color is not None:
325 return f'{left}[ {color}{title}{r} ]{right}'
331 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
333 """Draws a box with nice rounded corners.
335 >>> box('Title', 'This is text', width=30)
336 ╭────────────────────────────╮
340 ╰────────────────────────────╯
349 print(color + '╭' + '─' * w + '╮' + rset)
350 if title is not None:
352 color + '│' + rset + justify_string(title, width=w, alignment='c') + color + '│' + rset
354 print(color + '│' + ' ' * w + '│' + rset)
356 for line in justify_text(text, width=w - 2, alignment='l').split('\n'):
359 print(color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset)
360 print(color + '╰' + '─' * w + '╯' + rset)
363 if __name__ == '__main__':