2 # -*- coding: utf-8 -*-
4 """Utilities for dealing with "text"."""
10 from collections import defaultdict
11 from dataclasses import dataclass
12 from typing import Dict, Generator, List, Literal, Optional, Tuple
14 from ansi import fg, reset
16 logger = logging.getLogger(__file__)
27 def get_console_rows_columns() -> RowsColumns:
28 """Returns the number of rows/columns on the current console."""
30 from exec_utils import cmd
37 except Exception as e:
39 raise Exception('Can\'t determine console size?!') from e
40 return RowsColumns(int(rows), int(columns))
48 fgcolor=fg("school bus yellow"),
53 """Draws a progress graph."""
55 percent = current / total
56 ret = "\r" if redraw else "\n"
65 print(bar, end=ret, flush=True, file=sys.stderr)
73 fgcolor=fg("school bus yellow"),
78 """Returns a string containing a bar graph.
80 >>> bar_graph(0.5, fgcolor='', reset_seq='')
81 '[███████████████████████████████████ ] 50.0%'
85 if percentage < 0.0 or percentage > 1.0:
86 raise ValueError(percentage)
88 text = f"{percentage*100.0:2.1f}%"
91 whole_width = math.floor(percentage * width)
92 if whole_width == width:
95 elif whole_width == 0 and percentage > 0.0:
98 remainder_width = (percentage * width) % 1
99 part_width = math.floor(remainder_width * 8)
100 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
106 + " " * (width - whole_width - 1)
114 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
116 Makes a "sparkline" little inline histogram graph. Auto scales.
118 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
121 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
122 (73, 104, '█▇▆▆▃▂▄▁')
125 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
128 min_num, max_num = min(numbers), max(numbers)
129 span = max_num - min_num
131 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
133 return min_num, max_num, sline
136 def distribute_strings(
140 alignment: str = "c",
144 Distributes strings into a line with a particular justification.
146 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
148 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40, alignment='l')
150 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40, alignment='r')
154 subwidth = math.floor(width / len(strings))
156 for string in strings:
157 string = justify_string(string, width=subwidth, alignment=alignment, padding=padding)
159 while len(retval) > width:
160 retval = retval.replace(' ', ' ', 1)
161 while len(retval) < width:
162 retval = retval.replace(' ', ' ', 1)
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)
177 first, *rest, last = string.split()
178 w = width - (len(first) + 1 + len(last) + 1)
179 ret = first + padding + distribute_strings(rest, width=w, padding=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}'
330 if __name__ == '__main__':