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 """Number of columns"""
35 def get_console_rows_columns() -> RowsColumns:
38 The number of rows/columns on the current console or None
39 if we can't tell or an error occurred.
41 from exec_utils import cmd
43 rows: Optional[str] = os.environ.get('LINES', None)
44 cols: Optional[str] = os.environ.get('COLUMNS', None)
45 if not rows or not cols:
73 if not rows or not cols:
74 raise Exception('Can\'t determine console size?!')
75 return RowsColumns(int(rows), int(cols))
83 fgcolor=fg("school bus yellow"),
88 """Draws a progress graph at the current cursor position.
91 current: how many have we done so far?
92 total: how many are there to do total?
93 width: how many columns wide should be progress graph be?
94 fgcolor: what color should "done" part of the graph be?
95 left_end: the character at the left side of the graph
96 right_end: the character at the right side of the graph
97 redraw: if True, omit a line feed after the carriage return
98 so that subsequent calls to this method redraw the graph
101 percent = current / total
102 ret = "\r" if redraw else "\n"
111 print(bar, end=ret, flush=True, file=sys.stderr)
119 fgcolor=fg("school bus yellow"),
124 """Returns a string containing a bar graph.
127 percentage: percentage complete (0..100)
128 include_text: should we include the percentage text at the end?
129 width: how many columns wide should be progress graph be?
130 fgcolor: what color should "done" part of the graph be?
131 reset_seq: sequence to use to turn off color
132 left_end: the character at the left side of the graph
133 right_end: the character at the right side of the graph
135 >>> bar_graph(0.5, fgcolor='', reset_seq='')
136 '[███████████████████████████████████ ] 50.0%'
140 if percentage < 0.0 or percentage > 1.0:
141 raise ValueError(percentage)
143 text = f"{percentage*100.0:2.1f}%"
146 whole_width = math.floor(percentage * width)
147 if whole_width == width:
150 elif whole_width == 0 and percentage > 0.0:
153 remainder_width = (percentage * width) % 1
154 part_width = math.floor(remainder_width * 8)
155 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
161 + " " * (width - whole_width - 1)
169 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
171 Makes a "sparkline" little inline histogram graph. Auto scales.
174 numbers: the population over which to create the sparkline
177 a three tuple containing:
179 * the minimum number in the population
180 * the maximum number in the population
181 * a string representation of the population in a concise format
183 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
186 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
187 (73, 104, '█▇▆▆▃▂▄▁')
190 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
193 min_num, max_num = min(numbers), max(numbers)
194 span = max_num - min_num
196 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
198 return min_num, max_num, sline
201 def distribute_strings(
208 Distributes strings into a line for justified text.
211 strings: a list of string tokens to distribute
212 width: the width of the line to create
213 padding: the padding character to place between string chunks
216 The distributed, justified string.
218 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
221 ret = ' ' + ' '.join(strings) + ' '
222 assert len(string_utils.strip_ansi_sequences(ret)) < width
224 while len(string_utils.strip_ansi_sequences(ret)) < width:
225 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
229 ret = before + padding + after
236 def _justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
238 Justifies a string chunk by chunk.
241 string: the string to be justified
242 width: how wide to make the output
243 padding: what padding character to use between chunks
248 >>> _justify_string_by_chunk("This is a test", 40)
250 >>> _justify_string_by_chunk("This is a test", 20)
254 assert len(string_utils.strip_ansi_sequences(string)) <= width
256 first, *rest, last = string.split()
258 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
260 ret = first + distribute_strings(rest, width=w, padding=padding) + last
265 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
267 """Justify a string to width with left, right, center of justified
271 string: the string to justify
272 width: the width to justify the string to
273 alignment: a single character indicating the desired alignment:
274 * 'c' = centered within the width
275 * 'j' = justified at width
276 * 'l' = left alignment
277 * 'r' = right alignment
278 padding: the padding character to use while justifying
280 >>> justify_string('This is another test', width=40, alignment='c')
281 ' This is another test '
282 >>> justify_string('This is another test', width=40, alignment='l')
283 'This is another test '
284 >>> justify_string('This is another test', width=40, alignment='r')
285 ' This is another test'
286 >>> justify_string('This is another test', width=40, alignment='j')
287 'This is another test'
289 alignment = alignment[0]
291 while len(string_utils.strip_ansi_sequences(string)) < width:
294 elif alignment == "r":
295 string = padding + string
296 elif alignment == "j":
297 return _justify_string_by_chunk(string, width=width, padding=padding)
298 elif alignment == "c":
299 if len(string) % 2 == 0:
302 string = padding + string
308 def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str:
309 """Justifies text with left, right, centered or justified alignment
310 and optionally with initial indentation.
313 text: the text to be justified
314 width: the width at which to justify text
315 alignment: a single character indicating the desired alignment:
316 * 'c' = centered within the width
317 * 'j' = justified at width
318 * 'l' = left alignment
319 * 'r' = right alignment
320 indent_by: if non-zero, adds n prefix spaces to indent the text.
325 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
326 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
327 'This is a test of the emergency\\nbroadcast system. This is only a test.'
333 indent += ' ' * indent_by
336 for word in text.split():
338 len(string_utils.strip_ansi_sequences(line))
339 + len(string_utils.strip_ansi_sequences(word))
342 line = justify_string(line, width=width, alignment=alignment)
343 retval = retval + '\n' + line
345 line = line + ' ' + word
346 if len(string_utils.strip_ansi_sequences(line)) > 0:
348 retval += "\n" + justify_string(line[1:], width=width, alignment=alignment)
350 retval += "\n" + line[1:]
354 def generate_padded_columns(text: List[str]) -> Generator:
355 """Given a list of strings, break them into columns using :meth:`split`
356 and then compute the maximum width of each column. Finally,
357 distribute the columular chunks into the output padding each to
361 text: a list of strings to chunk into padded columns
364 padded columns based on text.split()
366 >>> for x in generate_padded_columns(
367 ... [ 'reading writing arithmetic',
368 ... 'mathematics psychology physics',
369 ... 'communications sociology anthropology' ]):
371 reading writing arithmetic
372 mathematics psychology physics
373 communications sociology anthropology
375 max_width: Dict[int, int] = defaultdict(int)
377 for pos, word in enumerate(line.split()):
378 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
382 for pos, word in enumerate(line.split()):
383 width = max_width[pos]
384 word = justify_string(word, width=width, alignment='l')
389 def wrap_string(text: str, n: int) -> str:
392 text: the string to be wrapped
393 n: the width after which to wrap text
396 The wrapped form of text
398 chunks = text.split()
402 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
406 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
410 class Indenter(contextlib.AbstractContextManager):
412 Context manager that indents stuff (even recursively). e.g.::
414 with Indenter(pad_count = 8) as i:
431 pad_prefix: Optional[str] = None,
435 """Construct an Indenter.
438 pad_prefix: an optional prefix to prepend to each line
439 pad_char: the character used to indent
440 pad_count: the number of pad_chars to use to indent
443 if pad_prefix is not None:
444 self.pad_prefix = pad_prefix
447 self.padding = pad_char * pad_count
453 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
459 def print(self, *arg, **kwargs):
460 text = string_utils.sprintf(*arg, **kwargs)
461 print(self.pad_prefix + self.padding * self.level + text, end='')
467 width: Optional[int] = None,
468 align: Optional[str] = None,
469 style: Optional[str] = 'solid',
470 color: Optional[str] = None,
473 Creates a nice header line with a title.
477 width: how wide to make the header
478 align: "left" or "right"
479 style: "ascii", "solid" or "dashed"
482 The header as a string.
484 >>> header('title', width=60, style='ascii')
485 '----[ title ]-----------------------------------------------'
489 width = get_console_rows_columns().columns
497 text_len = len(string_utils.strip_ansi_sequences(title))
500 right = width - (left + text_len + 4)
501 elif align == 'right':
503 left = width - (right + text_len + 4)
505 left = int((width - (text_len + 4)) / 2)
507 while left + text_len + 4 + right < width:
514 elif style == 'dashed':
528 return line_char * left + begin + col + ' ' + title + ' ' + reset_seq + end + line_char * right
532 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
535 Make a nice unicode box (optionally with color) around some text.
538 title: the title of the box
539 text: the text in the box
540 width: the box's width
541 color: the box's color
546 >>> print(box('title', 'this is some text', width=20).strip())
556 text = justify_text(text, width=width - 4, alignment='l')
557 return preformatted_box(title, text, width=width, color=color)
560 def preformatted_box(
561 title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = ''
563 """Creates a nice box with rounded corners and returns it as a string.
566 title: the title of the box
567 text: the text inside the box
568 width: the width of the box
569 color: the box's color
574 >>> print(preformatted_box('title', 'this\\nis\\nsome\\ntext', width=20).strip())
591 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
592 if title is not None:
597 + justify_string(title, width=w, alignment='c')
603 ret += color + '│' + ' ' * w + '│' + rset + '\n'
605 for line in text.split('\n'):
606 tw = len(string_utils.strip_ansi_sequences(line))
608 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
609 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
614 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
616 """Draws a box with nice rounded corners.
618 >>> print_box('Title', 'This is text', width=30)
619 ╭────────────────────────────╮
623 ╰────────────────────────────╯
625 >>> print_box(None, 'OK', width=6)
630 print(preformatted_box(title, text, width=width, color=color), end='')
633 if __name__ == '__main__':