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)
58 logger.debug('Rows: %s, cols: %s, tput rows.', rows, cols)
68 logger.debug('Rows: %s, cols: %s, tput cols.', rows, cols)
77 if not rows or not cols:
78 raise Exception('Can\'t determine console size?!')
79 return RowsColumns(int(rows), int(cols))
82 class BarGraphText(enum.Enum):
83 """What kind of text to include at the end of the bar graph?"""
86 """None, leave it blank."""
100 text: BarGraphText = BarGraphText.PERCENTAGE,
101 fgcolor=fg("school bus yellow"),
106 """Draws a progress graph at the current cursor position.
109 current: how many have we done so far?
110 total: how many are there to do total?
111 text: how should we render the text at the end?
112 width: how many columns wide should be progress graph be?
113 fgcolor: what color should "done" part of the graph be?
114 left_end: the character at the left side of the graph
115 right_end: the character at the right side of the graph
116 redraw: if True, omit a line feed after the carriage return
117 so that subsequent calls to this method redraw the graph
120 ret = "\r" if redraw else "\n"
121 bar = bar_graph_string(
130 print(bar, end=ret, flush=True, file=sys.stderr)
133 def _make_bar_graph_text(text: BarGraphText, current: int, total: int, percentage: float):
134 if text == BarGraphText.NONE:
136 elif text == BarGraphText.PERCENTAGE:
137 return f'{percentage:.1f}'
138 elif text == BarGraphText.FRACTION:
139 return f'{current} / {total}'
140 raise ValueError(text)
143 def bar_graph_string(
147 text: BarGraphText = BarGraphText.PERCENTAGE,
149 fgcolor=fg("school bus yellow"),
154 """Returns a string containing a bar graph.
157 current: how many have we done so far?
158 total: how many are there to do total?
159 text: how should we render the text at the end?
160 width: how many columns wide should be progress graph be?
161 fgcolor: what color should "done" part of the graph be?
162 reset_seq: sequence to use to turn off color
163 left_end: the character at the left side of the graph
164 right_end: the character at the right side of the graph
166 >>> bar_graph(5, 10, fgcolor='', reset_seq='')
167 '[███████████████████████████████████ ] 50.0%'
172 percentage = float(current) / float(total)
175 if percentage < 0.0 or percentage > 1.0:
176 raise ValueError(percentage)
177 text = _make_bar_graph_text(text, current, total, percentage)
178 whole_width = math.floor(percentage * width)
179 if whole_width == width:
182 elif whole_width == 0 and percentage > 0.0:
185 remainder_width = (percentage * width) % 1
186 part_width = math.floor(remainder_width * 8)
187 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
193 + " " * (width - whole_width - 1)
201 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
203 Makes a "sparkline" little inline histogram graph. Auto scales.
206 numbers: the population over which to create the sparkline
209 a three tuple containing:
211 * the minimum number in the population
212 * the maximum number in the population
213 * a string representation of the population in a concise format
215 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
218 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
219 (73, 104, '█▇▆▆▃▂▄▁')
222 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
225 min_num, max_num = min(numbers), max(numbers)
226 span = max_num - min_num
228 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
230 return min_num, max_num, sline
233 def distribute_strings(
240 Distributes strings into a line for justified text.
243 strings: a list of string tokens to distribute
244 width: the width of the line to create
245 padding: the padding character to place between string chunks
248 The distributed, justified string.
250 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
253 ret = ' ' + ' '.join(strings) + ' '
254 assert len(string_utils.strip_ansi_sequences(ret)) < width
256 while len(string_utils.strip_ansi_sequences(ret)) < width:
257 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
261 ret = before + padding + after
268 def _justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
270 Justifies a string chunk by chunk.
273 string: the string to be justified
274 width: how wide to make the output
275 padding: what padding character to use between chunks
280 >>> _justify_string_by_chunk("This is a test", 40)
282 >>> _justify_string_by_chunk("This is a test", 20)
286 assert len(string_utils.strip_ansi_sequences(string)) <= width
288 first, *rest, last = string.split()
290 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
292 ret = first + distribute_strings(rest, width=w, padding=padding) + last
297 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
299 """Justify a string to width with left, right, center of justified
303 string: the string to justify
304 width: the width to justify the string to
305 alignment: a single character indicating the desired alignment:
306 * 'c' = centered within the width
307 * 'j' = justified at width
308 * 'l' = left alignment
309 * 'r' = right alignment
310 padding: the padding character to use while justifying
312 >>> justify_string('This is another test', width=40, alignment='c')
313 ' This is another test '
314 >>> justify_string('This is another test', width=40, alignment='l')
315 'This is another test '
316 >>> justify_string('This is another test', width=40, alignment='r')
317 ' This is another test'
318 >>> justify_string('This is another test', width=40, alignment='j')
319 'This is another test'
321 alignment = alignment[0]
323 while len(string_utils.strip_ansi_sequences(string)) < width:
326 elif alignment == "r":
327 string = padding + string
328 elif alignment == "j":
329 return _justify_string_by_chunk(string, width=width, padding=padding)
330 elif alignment == "c":
331 if len(string) % 2 == 0:
334 string = padding + string
340 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
341 """Justifies text with left, right, centered or justified alignment
342 and optionally with initial indentation.
345 text: the text to be justified
346 width: the width at which to justify text
347 alignment: a single character indicating the desired alignment:
348 * 'c' = centered within the width
349 * 'j' = justified at width
350 * 'l' = left alignment
351 * 'r' = right alignment
352 indent_by: if non-zero, adds n prefix spaces to indent the text.
357 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
358 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
359 'This is a test of the emergency\\nbroadcast system. This is only a test.'
365 indent += ' ' * indent_by
368 for word in text.split():
370 len(string_utils.strip_ansi_sequences(line))
371 + len(string_utils.strip_ansi_sequences(word))
374 line = justify_string(line, width=width, alignment=alignment)
375 retval = retval + '\n' + line
377 line = line + ' ' + word
378 if len(string_utils.strip_ansi_sequences(line)) > 0:
380 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
382 retval += "\n" + line[1:]
386 def generate_padded_columns(text: List[str]) -> Generator:
387 """Given a list of strings, break them into columns using :meth:`split`
388 and then compute the maximum width of each column. Finally,
389 distribute the columular chunks into the output padding each to
393 text: a list of strings to chunk into padded columns
396 padded columns based on text.split()
398 >>> for x in generate_padded_columns(
399 ... [ 'reading writing arithmetic',
400 ... 'mathematics psychology physics',
401 ... 'communications sociology anthropology' ]):
403 reading writing arithmetic
404 mathematics psychology physics
405 communications sociology anthropology
407 max_width: Dict[int, int] = defaultdict(int)
409 for pos, word in enumerate(line.split()):
410 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
414 for pos, word in enumerate(line.split()):
415 width = max_width[pos]
416 word = justify_string(word, width=width, alignment='l')
421 def wrap_string(text: str, n: int) -> str:
424 text: the string to be wrapped
425 n: the width after which to wrap text
428 The wrapped form of text
430 chunks = text.split()
434 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
438 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
442 class Indenter(contextlib.AbstractContextManager):
444 Context manager that indents stuff (even recursively). e.g.::
446 with Indenter(pad_count = 8) as i:
463 pad_prefix: Optional[str] = None,
467 """Construct an Indenter.
470 pad_prefix: an optional prefix to prepend to each line
471 pad_char: the character used to indent
472 pad_count: the number of pad_chars to use to indent
475 if pad_prefix is not None:
476 self.pad_prefix = pad_prefix
479 self.padding = pad_char * pad_count
485 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
491 def print(self, *arg, **kwargs):
492 text = string_utils.sprintf(*arg, **kwargs)
493 print(self.pad_prefix + self.padding * self.level + text, end='')
499 width: Optional[int] = None,
500 align: Optional[str] = None,
501 style: Optional[str] = 'solid',
502 color: Optional[str] = None,
505 Creates a nice header line with a title.
509 width: how wide to make the header
510 align: "left" or "right"
511 style: "ascii", "solid" or "dashed"
514 The header as a string.
516 >>> header('title', width=60, style='ascii')
517 '----[ title ]-----------------------------------------------'
521 width = get_console_rows_columns().columns
529 text_len = len(string_utils.strip_ansi_sequences(title))
532 right = width - (left + text_len + 4)
533 elif align == 'right':
535 left = width - (right + text_len + 4)
537 left = int((width - (text_len + 4)) / 2)
539 while left + text_len + 4 + right < width:
546 elif style == 'dashed':
560 return line_char * left + begin + col + ' ' + title + ' ' + reset_seq + end + line_char * right
564 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
567 Make a nice unicode box (optionally with color) around some text.
570 title: the title of the box
571 text: the text in the box
572 width: the box's width
573 color: the box's color
578 >>> print(box('title', 'this is some text', width=20).strip())
588 text = justify_text(text, width=width - 4, alignment='l')
589 return preformatted_box(title, text, width=width, color=color)
592 def preformatted_box(
593 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
595 """Creates a nice box with rounded corners and returns it as a string.
598 title: the title of the box
599 text: the text inside the box
600 width: the width of the box
601 color: the box's color
606 >>> print(preformatted_box('title', 'this\\nis\\nsome\\ntext', width=20).strip())
623 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
624 if title is not None:
629 + justify_string(title, width=w, alignment='c')
635 ret += color + '│' + ' ' * w + '│' + rset + '\n'
637 for line in text.split('\n'):
638 tw = len(string_utils.strip_ansi_sequences(line))
640 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
641 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
646 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
648 """Draws a box with nice rounded corners.
650 >>> print_box('Title', 'This is text', width=30)
651 ╭────────────────────────────╮
655 ╰────────────────────────────╯
657 >>> print_box(None, 'OK', width=6)
662 print(preformatted_box(title, text, width=width, color=color), end='')
665 if __name__ == '__main__':