2 # -*- coding: utf-8 -*-
4 # © Copyright 2021-2022, Scott Gasch
6 """Utilities for dealing with "text"."""
14 from collections import defaultdict
15 from dataclasses import dataclass
16 from typing import Dict, Generator, List, Literal, Optional, Tuple
19 from ansi import fg, reset
21 logger = logging.getLogger(__file__)
32 def get_console_rows_columns() -> RowsColumns:
33 """Returns the number of rows/columns on the current console."""
35 from exec_utils import cmd
37 rows: Optional[str] = os.environ.get('LINES', None)
38 cols: Optional[str] = os.environ.get('COLUMNS', None)
39 if not rows or not cols:
67 if not rows or not cols:
68 raise Exception('Can\'t determine console size?!')
69 return RowsColumns(int(rows), int(cols))
77 fgcolor=fg("school bus yellow"),
82 """Draws a progress graph."""
84 percent = current / total
85 ret = "\r" if redraw else "\n"
94 print(bar, end=ret, flush=True, file=sys.stderr)
102 fgcolor=fg("school bus yellow"),
107 """Returns a string containing a bar graph.
109 >>> bar_graph(0.5, fgcolor='', reset_seq='')
110 '[███████████████████████████████████ ] 50.0%'
114 if percentage < 0.0 or percentage > 1.0:
115 raise ValueError(percentage)
117 text = f"{percentage*100.0:2.1f}%"
120 whole_width = math.floor(percentage * width)
121 if whole_width == width:
124 elif whole_width == 0 and percentage > 0.0:
127 remainder_width = (percentage * width) % 1
128 part_width = math.floor(remainder_width * 8)
129 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
135 + " " * (width - whole_width - 1)
143 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
145 Makes a "sparkline" little inline histogram graph. Auto scales.
147 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
150 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
151 (73, 104, '█▇▆▆▃▂▄▁')
154 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
157 min_num, max_num = min(numbers), max(numbers)
158 span = max_num - min_num
160 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
162 return min_num, max_num, sline
165 def distribute_strings(
172 Distributes strings into a line for justified text.
174 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
178 ret = ' ' + ' '.join(strings) + ' '
179 assert len(string_utils.strip_ansi_sequences(ret)) < width
181 while len(string_utils.strip_ansi_sequences(ret)) < width:
182 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
186 ret = before + padding + after
193 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
197 >>> justify_string_by_chunk("This is a test", 40)
199 >>> justify_string_by_chunk("This is a test", 20)
203 assert len(string_utils.strip_ansi_sequences(string)) <= width
205 first, *rest, last = string.split()
207 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
209 ret = first + distribute_strings(rest, width=w, padding=padding) + last
214 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
218 >>> justify_string('This is another test', width=40, alignment='c')
219 ' This is another test '
220 >>> justify_string('This is another test', width=40, alignment='l')
221 'This is another test '
222 >>> justify_string('This is another test', width=40, alignment='r')
223 ' This is another test'
224 >>> justify_string('This is another test', width=40, alignment='j')
225 'This is another test'
228 alignment = alignment[0]
230 while len(string_utils.strip_ansi_sequences(string)) < width:
233 elif alignment == "r":
234 string = padding + string
235 elif alignment == "j":
236 return justify_string_by_chunk(string, width=width, padding=padding)
237 elif alignment == "c":
238 if len(string) % 2 == 0:
241 string = padding + string
247 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
249 Justifies text optionally with initial indentation.
251 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
252 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
253 'This is a test of the emergency\\nbroadcast system. This is only a test.'
259 indent += ' ' * indent_by
262 for word in text.split():
264 len(string_utils.strip_ansi_sequences(line))
265 + len(string_utils.strip_ansi_sequences(word))
268 line = justify_string(line, width=width, alignment=alignment)
269 retval = retval + '\n' + line
271 line = line + ' ' + word
272 if len(string_utils.strip_ansi_sequences(line)) > 0:
274 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
276 retval += "\n" + line[1:]
280 def generate_padded_columns(text: List[str]) -> Generator:
281 max_width: Dict[int, int] = defaultdict(int)
283 for pos, word in enumerate(line.split()):
284 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
288 for pos, word in enumerate(line.split()):
289 width = max_width[pos]
290 word = justify_string(word, width=width, alignment='l')
295 def wrap_string(text: str, n: int) -> str:
296 chunks = text.split()
300 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
304 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
308 class Indenter(contextlib.AbstractContextManager):
310 with Indenter(pad_count = 8) as i:
322 pad_prefix: Optional[str] = None,
327 if pad_prefix is not None:
328 self.pad_prefix = pad_prefix
331 self.padding = pad_char * pad_count
337 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
343 def print(self, *arg, **kwargs):
344 text = string_utils.sprintf(*arg, **kwargs)
345 print(self.pad_prefix + self.padding * self.level + text, end='')
351 width: Optional[int] = None,
352 align: Optional[str] = None,
353 style: Optional[str] = 'solid',
354 color: Optional[str] = None,
357 Returns a nice header line with a title.
359 >>> header('title', width=60, style='ascii')
360 '----[ title ]-----------------------------------------------'
365 width = get_console_rows_columns().columns
373 text_len = len(string_utils.strip_ansi_sequences(title))
376 right = width - (left + text_len + 4)
377 elif align == 'right':
379 left = width - (right + text_len + 4)
381 left = int((width - (text_len + 4)) / 2)
383 while left + text_len + 4 + right < width:
390 elif style == 'dashed':
404 return line_char * left + begin + col + ' ' + title + ' ' + reset_seq + end + line_char * right
408 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
412 text = justify_text(text, width=width - 4, alignment='l')
413 return preformatted_box(title, text, width=width, color=color)
416 def preformatted_box(
417 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
426 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
427 if title is not None:
432 + justify_string(title, width=w, alignment='c')
438 ret += color + '│' + ' ' * w + '│' + rset + '\n'
440 for line in text.split('\n'):
441 tw = len(string_utils.strip_ansi_sequences(line))
443 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
444 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
449 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
451 """Draws a box with nice rounded corners.
453 >>> print_box('Title', 'This is text', width=30)
454 ╭────────────────────────────╮
458 ╰────────────────────────────╯
460 >>> print_box(None, 'OK', width=6)
466 print(preformatted_box(title, text, width=width, color=color), end='')
469 if __name__ == '__main__':