2 # -*- coding: utf-8 -*-
4 # © Copyright 2021-2022, Scott Gasch
6 """Utilities for dealing with "text"."""
15 from collections import defaultdict
16 from dataclasses import dataclass
17 from typing import Dict, Generator, List, Literal, Optional, Tuple
20 from ansi import fg, reset
22 logger = logging.getLogger(__file__)
33 """Number of columns"""
36 def get_console_rows_columns() -> RowsColumns:
39 The number of rows/columns on the current console or None
40 if we can't tell or an error occurred.
42 from exec_utils import cmd
44 rows: Optional[str] = os.environ.get('LINES', None)
45 cols: Optional[str] = os.environ.get('COLUMNS', None)
46 if not rows or not cols:
47 logger.debug('Rows: %s, cols: %s, trying stty.', rows, cols)
53 except Exception as e:
59 logger.debug('Rows: %s, cols: %s, tput rows.', rows, cols)
65 except Exception as e:
70 logger.debug('Rows: %s, cols: %s, tput cols.', rows, cols)
76 except Exception as e:
80 if not rows or not cols:
81 raise Exception('Can\'t determine console size?!')
82 return RowsColumns(int(rows), int(cols))
85 class BarGraphText(enum.Enum):
86 """What kind of text to include at the end of the bar graph?"""
89 """None, leave it blank."""
103 text: BarGraphText = BarGraphText.PERCENTAGE,
104 fgcolor=fg("school bus yellow"),
109 """Draws a progress graph at the current cursor position.
112 current: how many have we done so far?
113 total: how many are there to do total?
114 text: how should we render the text at the end?
115 width: how many columns wide should be progress graph be?
116 fgcolor: what color should "done" part of the graph be?
117 left_end: the character at the left side of the graph
118 right_end: the character at the right side of the graph
119 redraw: if True, omit a line feed after the carriage return
120 so that subsequent calls to this method redraw the graph
123 ret = "\r" if redraw else "\n"
124 bar = bar_graph_string(
133 print(bar, end=ret, flush=True, file=sys.stderr)
136 def _make_bar_graph_text(text: BarGraphText, current: int, total: int, percentage: float):
137 if text == BarGraphText.NONE:
139 elif text == BarGraphText.PERCENTAGE:
140 return f'{percentage:.1f}'
141 elif text == BarGraphText.FRACTION:
142 return f'{current} / {total}'
143 raise ValueError(text)
146 def bar_graph_string(
150 text: BarGraphText = BarGraphText.PERCENTAGE,
152 fgcolor=fg("school bus yellow"),
157 """Returns a string containing a bar graph.
160 current: how many have we done so far?
161 total: how many are there to do total?
162 text: how should we render the text at the end?
163 width: how many columns wide should be progress graph be?
164 fgcolor: what color should "done" part of the graph be?
165 reset_seq: sequence to use to turn off color
166 left_end: the character at the left side of the graph
167 right_end: the character at the right side of the graph
169 >>> bar_graph(5, 10, fgcolor='', reset_seq='')
170 '[███████████████████████████████████ ] 50.0%'
175 percentage = float(current) / float(total)
178 if percentage < 0.0 or percentage > 1.0:
179 raise ValueError(percentage)
180 text = _make_bar_graph_text(text, current, total, percentage)
181 whole_width = math.floor(percentage * width)
182 if whole_width == width:
185 elif whole_width == 0 and percentage > 0.0:
188 remainder_width = (percentage * width) % 1
189 part_width = math.floor(remainder_width * 8)
190 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
196 + " " * (width - whole_width - 1)
204 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
206 Makes a "sparkline" little inline histogram graph. Auto scales.
209 numbers: the population over which to create the sparkline
212 a three tuple containing:
214 * the minimum number in the population
215 * the maximum number in the population
216 * a string representation of the population in a concise format
218 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
221 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
222 (73, 104, '█▇▆▆▃▂▄▁')
225 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
228 min_num, max_num = min(numbers), max(numbers)
229 span = max_num - min_num
231 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
233 return min_num, max_num, sline
236 def distribute_strings(
243 Distributes strings into a line for justified text.
246 strings: a list of string tokens to distribute
247 width: the width of the line to create
248 padding: the padding character to place between string chunks
251 The distributed, justified string.
253 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
256 ret = ' ' + ' '.join(strings) + ' '
257 assert len(string_utils.strip_ansi_sequences(ret)) < width
259 while len(string_utils.strip_ansi_sequences(ret)) < width:
260 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
264 ret = before + padding + after
271 def _justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
273 Justifies a string chunk by chunk.
276 string: the string to be justified
277 width: how wide to make the output
278 padding: what padding character to use between chunks
283 >>> _justify_string_by_chunk("This is a test", 40)
285 >>> _justify_string_by_chunk("This is a test", 20)
289 assert len(string_utils.strip_ansi_sequences(string)) <= width
291 first, *rest, last = string.split()
293 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
295 ret = first + distribute_strings(rest, width=w, padding=padding) + last
300 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
302 """Justify a string to width with left, right, center of justified
306 string: the string to justify
307 width: the width to justify the string to
308 alignment: a single character indicating the desired alignment:
309 * 'c' = centered within the width
310 * 'j' = justified at width
311 * 'l' = left alignment
312 * 'r' = right alignment
313 padding: the padding character to use while justifying
315 >>> justify_string('This is another test', width=40, alignment='c')
316 ' This is another test '
317 >>> justify_string('This is another test', width=40, alignment='l')
318 'This is another test '
319 >>> justify_string('This is another test', width=40, alignment='r')
320 ' This is another test'
321 >>> justify_string('This is another test', width=40, alignment='j')
322 'This is another test'
324 alignment = alignment[0]
326 while len(string_utils.strip_ansi_sequences(string)) < width:
329 elif alignment == "r":
330 string = padding + string
331 elif alignment == "j":
332 return _justify_string_by_chunk(string, width=width, padding=padding)
333 elif alignment == "c":
334 if len(string) % 2 == 0:
337 string = padding + string
343 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
344 """Justifies text with left, right, centered or justified alignment
345 and optionally with initial indentation.
348 text: the text to be justified
349 width: the width at which to justify text
350 alignment: a single character indicating the desired alignment:
351 * 'c' = centered within the width
352 * 'j' = justified at width
353 * 'l' = left alignment
354 * 'r' = right alignment
355 indent_by: if non-zero, adds n prefix spaces to indent the text.
360 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
361 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
362 'This is a test of the emergency\\nbroadcast system. This is only a test.'
368 indent += ' ' * indent_by
371 for word in text.split():
373 len(string_utils.strip_ansi_sequences(line))
374 + len(string_utils.strip_ansi_sequences(word))
377 line = justify_string(line, width=width, alignment=alignment)
378 retval = retval + '\n' + line
380 line = line + ' ' + word
381 if len(string_utils.strip_ansi_sequences(line)) > 0:
383 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
385 retval += "\n" + line[1:]
389 def generate_padded_columns(text: List[str]) -> Generator:
390 """Given a list of strings, break them into columns using :meth:`split`
391 and then compute the maximum width of each column. Finally,
392 distribute the columular chunks into the output padding each to
396 text: a list of strings to chunk into padded columns
399 padded columns based on text.split()
401 >>> for x in generate_padded_columns(
402 ... [ 'reading writing arithmetic',
403 ... 'mathematics psychology physics',
404 ... 'communications sociology anthropology' ]):
406 reading writing arithmetic
407 mathematics psychology physics
408 communications sociology anthropology
410 max_width: Dict[int, int] = defaultdict(int)
412 for pos, word in enumerate(line.split()):
413 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
417 for pos, word in enumerate(line.split()):
418 width = max_width[pos]
419 word = justify_string(word, width=width, alignment='l')
424 def wrap_string(text: str, n: int) -> str:
427 text: the string to be wrapped
428 n: the width after which to wrap text
431 The wrapped form of text
433 chunks = text.split()
437 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
441 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
445 class Indenter(contextlib.AbstractContextManager):
447 Context manager that indents stuff (even recursively). e.g.::
449 with Indenter(pad_count = 8) as i:
466 pad_prefix: Optional[str] = None,
470 """Construct an Indenter.
473 pad_prefix: an optional prefix to prepend to each line
474 pad_char: the character used to indent
475 pad_count: the number of pad_chars to use to indent
478 if pad_prefix is not None:
479 self.pad_prefix = pad_prefix
482 self.padding = pad_char * pad_count
488 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
494 def print(self, *arg, **kwargs):
495 text = string_utils.sprintf(*arg, **kwargs)
496 print(self.pad_prefix + self.padding * self.level + text, end='')
502 width: Optional[int] = None,
503 align: Optional[str] = None,
504 style: Optional[str] = 'solid',
505 color: Optional[str] = None,
508 Creates a nice header line with a title.
512 width: how wide to make the header
513 align: "left" or "right"
514 style: "ascii", "solid" or "dashed"
517 The header as a string.
519 >>> header('title', width=60, style='ascii')
520 '----[ title ]-----------------------------------------------'
524 width = get_console_rows_columns().columns
532 text_len = len(string_utils.strip_ansi_sequences(title))
535 right = width - (left + text_len + 4)
536 elif align == 'right':
538 left = width - (right + text_len + 4)
540 left = int((width - (text_len + 4)) / 2)
542 while left + text_len + 4 + right < width:
549 elif style == 'dashed':
563 return line_char * left + begin + col + ' ' + title + ' ' + reset_seq + end + line_char * right
567 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
570 Make a nice unicode box (optionally with color) around some text.
573 title: the title of the box
574 text: the text in the box
575 width: the box's width
576 color: the box's color
581 >>> print(box('title', 'this is some text', width=20).strip())
591 text = justify_text(text, width=width - 4, alignment='l')
592 return preformatted_box(title, text, width=width, color=color)
595 def preformatted_box(
596 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
598 """Creates a nice box with rounded corners and returns it as a string.
601 title: the title of the box
602 text: the text inside the box
603 width: the width of the box
604 color: the box's color
609 >>> print(preformatted_box('title', 'this\\nis\\nsome\\ntext', width=20).strip())
626 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
627 if title is not None:
632 + justify_string(title, width=w, alignment='c')
638 ret += color + '│' + ' ' * w + '│' + rset + '\n'
640 for line in text.split('\n'):
641 tw = len(string_utils.strip_ansi_sequences(line))
643 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
644 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
649 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
651 """Draws a box with nice rounded corners.
653 >>> print_box('Title', 'This is text', width=30)
654 ╭────────────────────────────╮
658 ╰────────────────────────────╯
660 >>> print_box(None, 'OK', width=6)
665 print(preformatted_box(title, text, width=width, color=color), end='')
668 if __name__ == '__main__':