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 Context manager that indents stuff (even recursively). e.g.::
312 with Indenter(pad_count = 8) as i:
330 pad_prefix: Optional[str] = None,
335 if pad_prefix is not None:
336 self.pad_prefix = pad_prefix
339 self.padding = pad_char * pad_count
345 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
351 def print(self, *arg, **kwargs):
352 text = string_utils.sprintf(*arg, **kwargs)
353 print(self.pad_prefix + self.padding * self.level + text, end='')
359 width: Optional[int] = None,
360 align: Optional[str] = None,
361 style: Optional[str] = 'solid',
362 color: Optional[str] = None,
365 Returns a nice header line with a title.
367 >>> header('title', width=60, style='ascii')
368 '----[ title ]-----------------------------------------------'
373 width = get_console_rows_columns().columns
381 text_len = len(string_utils.strip_ansi_sequences(title))
384 right = width - (left + text_len + 4)
385 elif align == 'right':
387 left = width - (right + text_len + 4)
389 left = int((width - (text_len + 4)) / 2)
391 while left + text_len + 4 + right < width:
398 elif style == 'dashed':
412 return line_char * left + begin + col + ' ' + title + ' ' + reset_seq + end + line_char * right
416 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
420 text = justify_text(text, width=width - 4, alignment='l')
421 return preformatted_box(title, text, width=width, color=color)
424 def preformatted_box(
425 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
434 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
435 if title is not None:
440 + justify_string(title, width=w, alignment='c')
446 ret += color + '│' + ' ' * w + '│' + rset + '\n'
448 for line in text.split('\n'):
449 tw = len(string_utils.strip_ansi_sequences(line))
451 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
452 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
457 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
459 """Draws a box with nice rounded corners.
461 >>> print_box('Title', 'This is text', width=30)
462 ╭────────────────────────────╮
466 ╰────────────────────────────╯
468 >>> print_box(None, 'OK', width=6)
474 print(preformatted_box(title, text, width=width, color=color), end='')
477 if __name__ == '__main__':