2 # -*- coding: utf-8 -*-
4 """Utilities for dealing with "text"."""
10 from collections import defaultdict
11 from typing import Dict, Generator, List, Literal, NamedTuple, Optional, Tuple
13 from ansi import fg, reset
15 logger = logging.getLogger(__file__)
18 class RowsColumns(NamedTuple):
25 def get_console_rows_columns() -> RowsColumns:
26 """Returns the number of rows/columns on the current console."""
28 from exec_utils import cmd
35 except Exception as e:
37 raise Exception('Can\'t determine console size?!') from e
38 return RowsColumns(int(rows), int(columns))
46 fgcolor=fg("school bus yellow"),
51 """Draws a progress graph."""
53 percent = current / total
54 ret = "\r" if redraw else "\n"
63 print(bar, end=ret, flush=True, file=sys.stderr)
71 fgcolor=fg("school bus yellow"),
76 """Returns a string containing a bar graph.
78 >>> bar_graph(0.5, fgcolor='', reset='')
79 '[███████████████████████████████████ ] 50.0%'
83 if percentage < 0.0 or percentage > 1.0:
84 raise ValueError(percentage)
86 text = f"{percentage*100.0:2.1f}%"
89 whole_width = math.floor(percentage * width)
90 if whole_width == width:
93 elif whole_width == 0 and percentage > 0.0:
96 remainder_width = (percentage * width) % 1
97 part_width = math.floor(remainder_width * 8)
98 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
104 + " " * (width - whole_width - 1)
112 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
114 Makes a "sparkline" little inline histogram graph. Auto scales.
116 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
119 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
120 (73, 104, '█▇▆▆▃▂▄▁')
123 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
126 min_num, max_num = min(numbers), max(numbers)
127 span = max_num - min_num
129 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
131 return min_num, max_num, sline
134 def distribute_strings(
138 alignment: str = "c",
142 Distributes strings into a line with a particular justification.
144 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
146 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40, alignment='l')
148 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40, alignment='r')
152 subwidth = math.floor(width / len(strings))
154 for string in strings:
155 string = justify_string(string, width=subwidth, alignment=alignment, padding=padding)
157 while len(retval) > width:
158 retval = retval.replace(' ', ' ', 1)
159 while len(retval) < width:
160 retval = retval.replace(' ', ' ', 1)
164 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
168 >>> justify_string_by_chunk("This is a test", 40)
170 >>> justify_string_by_chunk("This is a test", 20)
175 first, *rest, last = string.split()
176 w = width - (len(first) + 1 + len(last) + 1)
177 ret = first + padding + distribute_strings(rest, width=w, padding=padding) + padding + last
182 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
186 >>> justify_string('This is another test', width=40, alignment='c')
187 ' This is another test '
188 >>> justify_string('This is another test', width=40, alignment='l')
189 'This is another test '
190 >>> justify_string('This is another test', width=40, alignment='r')
191 ' This is another test'
192 >>> justify_string('This is another test', width=40, alignment='j')
193 'This is another test'
196 alignment = alignment[0]
198 while len(string) < width:
201 elif alignment == "r":
202 string = padding + string
203 elif alignment == "j":
204 return justify_string_by_chunk(string, width=width, padding=padding)
205 elif alignment == "c":
206 if len(string) % 2 == 0:
209 string = padding + string
215 def justify_text(text: str, *, width: int = 80, alignment: str = "c") -> str:
219 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
220 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
221 'This is a test of the emergency\\nbroadcast system. This is only a test.'
225 for word in text.split():
226 if len(line) + len(word) > width:
228 line = justify_string(line, width=width, alignment=alignment)
229 retval = retval + "\n" + line
231 line = line + " " + word
233 retval += "\n" + line[1:]
237 def generate_padded_columns(text: List[str]) -> Generator:
238 max_width: Dict[int, int] = defaultdict(int)
240 for pos, word in enumerate(line.split()):
241 max_width[pos] = max(max_width[pos], len(word))
245 for pos, word in enumerate(line.split()):
246 width = max_width[pos]
247 word = justify_string(word, width=width, alignment='l')
252 def wrap_string(text: str, n: int) -> str:
253 chunks = text.split()
257 if width + len(chunk) > n:
261 width += len(chunk) + 1
265 class Indenter(contextlib.AbstractContextManager):
267 with Indenter(pad_count = 8) as i:
278 pad_prefix: Optional[str] = None,
283 if pad_prefix is not None:
284 self.pad_prefix = pad_prefix
287 self.padding = pad_char * pad_count
293 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
299 def print(self, *arg, **kwargs):
302 text = string_utils.sprintf(*arg, **kwargs)
303 print(self.pad_prefix + self.padding * self.level + text, end='')
306 def header(title: str, *, width: int = 80, color: str = ''):
308 Returns a nice header line with a title.
310 >>> header('title', width=60, color='')
311 '----[ title ]-----------------------------------------------'
318 right = (w - 4) * '-'
319 if color != '' and color is not None:
323 return f'{left}[ {color}{title}{r} ]{right}'
328 if __name__ == '__main__':