2 # -*- coding: utf-8 -*-
4 # © Copyright 2021-2022, Scott Gasch
6 """Utilities for dealing with "text"."""
13 from collections import defaultdict
14 from dataclasses import dataclass
15 from typing import Dict, Generator, List, Literal, Optional, Tuple
18 from ansi import fg, reset
20 logger = logging.getLogger(__file__)
31 def get_console_rows_columns() -> RowsColumns:
32 """Returns the number of rows/columns on the current console."""
34 from exec_utils import cmd
41 except Exception as e:
43 raise Exception('Can\'t determine console size?!') from e
44 return RowsColumns(int(rows), int(columns))
52 fgcolor=fg("school bus yellow"),
57 """Draws a progress graph."""
59 percent = current / total
60 ret = "\r" if redraw else "\n"
69 print(bar, end=ret, flush=True, file=sys.stderr)
77 fgcolor=fg("school bus yellow"),
82 """Returns a string containing a bar graph.
84 >>> bar_graph(0.5, fgcolor='', reset_seq='')
85 '[███████████████████████████████████ ] 50.0%'
89 if percentage < 0.0 or percentage > 1.0:
90 raise ValueError(percentage)
92 text = f"{percentage*100.0:2.1f}%"
95 whole_width = math.floor(percentage * width)
96 if whole_width == width:
99 elif whole_width == 0 and percentage > 0.0:
102 remainder_width = (percentage * width) % 1
103 part_width = math.floor(remainder_width * 8)
104 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
110 + " " * (width - whole_width - 1)
118 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
120 Makes a "sparkline" little inline histogram graph. Auto scales.
122 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
125 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
126 (73, 104, '█▇▆▆▃▂▄▁')
129 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
132 min_num, max_num = min(numbers), max(numbers)
133 span = max_num - min_num
135 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
137 return min_num, max_num, sline
140 def distribute_strings(
147 Distributes strings into a line for justified text.
149 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
153 ret = ' ' + ' '.join(strings) + ' '
154 assert len(string_utils.strip_ansi_sequences(ret)) < width
156 while len(string_utils.strip_ansi_sequences(ret)) < width:
157 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
161 ret = before + padding + after
168 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
172 >>> justify_string_by_chunk("This is a test", 40)
174 >>> justify_string_by_chunk("This is a test", 20)
178 assert len(string_utils.strip_ansi_sequences(string)) <= width
180 first, *rest, last = string.split()
182 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
184 ret = first + distribute_strings(rest, width=w, padding=padding) + last
189 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
193 >>> justify_string('This is another test', width=40, alignment='c')
194 ' This is another test '
195 >>> justify_string('This is another test', width=40, alignment='l')
196 'This is another test '
197 >>> justify_string('This is another test', width=40, alignment='r')
198 ' This is another test'
199 >>> justify_string('This is another test', width=40, alignment='j')
200 'This is another test'
203 alignment = alignment[0]
205 while len(string_utils.strip_ansi_sequences(string)) < width:
208 elif alignment == "r":
209 string = padding + string
210 elif alignment == "j":
211 return justify_string_by_chunk(string, width=width, padding=padding)
212 elif alignment == "c":
213 if len(string) % 2 == 0:
216 string = padding + string
222 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
224 Justifies text optionally with initial indentation.
226 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
227 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
228 'This is a test of the emergency\\nbroadcast system. This is only a test.'
234 indent += ' ' * indent_by
237 for word in text.split():
239 len(string_utils.strip_ansi_sequences(line))
240 + len(string_utils.strip_ansi_sequences(word))
243 line = justify_string(line, width=width, alignment=alignment)
244 retval = retval + '\n' + line
246 line = line + ' ' + word
247 if len(string_utils.strip_ansi_sequences(line)) > 0:
249 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
251 retval += "\n" + line[1:]
255 def generate_padded_columns(text: List[str]) -> Generator:
256 max_width: Dict[int, int] = defaultdict(int)
258 for pos, word in enumerate(line.split()):
259 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
263 for pos, word in enumerate(line.split()):
264 width = max_width[pos]
265 word = justify_string(word, width=width, alignment='l')
270 def wrap_string(text: str, n: int) -> str:
271 chunks = text.split()
275 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
279 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
283 class Indenter(contextlib.AbstractContextManager):
285 with Indenter(pad_count = 8) as i:
297 pad_prefix: Optional[str] = None,
302 if pad_prefix is not None:
303 self.pad_prefix = pad_prefix
306 self.padding = pad_char * pad_count
312 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
318 def print(self, *arg, **kwargs):
319 text = string_utils.sprintf(*arg, **kwargs)
320 print(self.pad_prefix + self.padding * self.level + text, end='')
326 width: Optional[int] = None,
327 align: Optional[str] = None,
328 style: Optional[str] = 'solid',
331 Returns a nice header line with a title.
333 >>> header('title', width=60, style='ascii')
334 '----[ title ]-----------------------------------------------'
338 width = get_console_rows_columns().columns
344 text_len = len(string_utils.strip_ansi_sequences(title))
347 right = width - (left + text_len + 4)
348 elif align == 'right':
350 left = width - (right + text_len + 4)
352 left = int((width - (text_len + 4)) / 2)
354 while left + text_len + 4 + right < width:
361 elif style == 'dashed':
369 return line_char * left + begin + ' ' + title + ' ' + end + line_char * right
373 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
377 text = justify_text(text, width=width - 4, alignment='l')
378 return preformatted_box(title, text, width=width, color=color)
381 def preformatted_box(
382 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
391 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
392 if title is not None:
397 + justify_string(title, width=w, alignment='c')
403 ret += color + '│' + ' ' * w + '│' + rset + '\n'
405 for line in text.split('\n'):
406 tw = len(string_utils.strip_ansi_sequences(line))
408 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
409 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
414 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
416 """Draws a box with nice rounded corners.
418 >>> print_box('Title', 'This is text', width=30)
419 ╭────────────────────────────╮
423 ╰────────────────────────────╯
425 >>> print_box(None, 'OK', width=6)
431 print(preformatted_box(title, text, width=width, color=color), end='')
434 if __name__ == '__main__':