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='')
324 width: Optional[int] = None,
325 align: Optional[str] = None,
326 style: Optional[str] = 'solid',
329 Returns a nice header line with a title.
331 >>> header('title', width=60, style='ascii')
332 '----[ title ]-----------------------------------------------'
336 width = get_console_rows_columns().columns
342 text_len = len(string_utils.strip_ansi_sequences(title))
345 right = width - (left + text_len + 4)
346 elif align == 'right':
348 left = width - (right + text_len + 4)
350 left = int((width - (text_len + 4)) / 2)
352 while left + text_len + 4 + right < width:
359 elif style == 'dashed':
367 return line_char * left + begin + ' ' + title + ' ' + end + line_char * right
371 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
380 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
381 if title is not None:
386 + justify_string(title, width=w, alignment='c')
392 ret += color + '│' + ' ' * w + '│' + rset + '\n'
394 for line in justify_text(text, width=w - 2, alignment='l').split('\n'):
395 tw = len(string_utils.strip_ansi_sequences(line))
397 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
398 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
403 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
405 """Draws a box with nice rounded corners.
407 >>> print_box('Title', 'This is text', width=30)
408 ╭────────────────────────────╮
412 ╰────────────────────────────╯
414 >>> print_box(None, 'OK', width=6)
420 print(box(title, text, width=width, color=color), end='')
423 if __name__ == '__main__':