#!/usr/bin/env python3 # -*- coding: utf-8 -*- # © Copyright 2021-2022, Scott Gasch """Utilities for dealing with "text".""" import contextlib import logging import math import re import sys from collections import defaultdict from dataclasses import dataclass from typing import Dict, Generator, List, Literal, Optional, Tuple import string_utils from ansi import fg, reset logger = logging.getLogger(__file__) @dataclass class RowsColumns: """Row + Column""" rows: int = 0 columns: int = 0 def get_console_rows_columns() -> RowsColumns: """Returns the number of rows/columns on the current console.""" from exec_utils import cmd try: rows, columns = cmd( "stty size", timeout_seconds=1.0, ).split() except Exception as e: logger.exception(e) raise Exception('Can\'t determine console size?!') from e return RowsColumns(int(rows), int(columns)) def progress_graph( current: int, total: int, *, width=70, fgcolor=fg("school bus yellow"), left_end="[", right_end="]", redraw=True, ) -> None: """Draws a progress graph.""" percent = current / total ret = "\r" if redraw else "\n" bar = bar_graph( percent, include_text=True, width=width, fgcolor=fgcolor, left_end=left_end, right_end=right_end, ) print(bar, end=ret, flush=True, file=sys.stderr) def bar_graph( percentage: float, *, include_text=True, width=70, fgcolor=fg("school bus yellow"), reset_seq=reset(), left_end="[", right_end="]", ) -> str: """Returns a string containing a bar graph. >>> bar_graph(0.5, fgcolor='', reset_seq='') '[███████████████████████████████████ ] 50.0%' """ if percentage < 0.0 or percentage > 1.0: raise ValueError(percentage) if include_text: text = f"{percentage*100.0:2.1f}%" else: text = "" whole_width = math.floor(percentage * width) if whole_width == width: whole_width -= 1 part_char = "▉" elif whole_width == 0 and percentage > 0.0: part_char = "▏" else: remainder_width = (percentage * width) % 1 part_width = math.floor(remainder_width * 8) part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width] return ( left_end + fgcolor + "█" * whole_width + part_char + " " * (width - whole_width - 1) + reset_seq + right_end + " " + text ) def sparkline(numbers: List[float]) -> Tuple[float, float, str]: """ Makes a "sparkline" little inline histogram graph. Auto scales. >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7]) (1, 10, '▁▁▂▄█▂▄▆') >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73]) (73, 104, '█▇▆▆▃▂▄▁') """ _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608 barcount = len(_bar) min_num, max_num = min(numbers), max(numbers) span = max_num - min_num sline = ''.join( _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers ) return min_num, max_num, sline def distribute_strings( strings: List[str], *, width: int = 80, padding: str = " ", ) -> str: """ Distributes strings into a line for justified text. >>> distribute_strings(['this', 'is', 'a', 'test'], width=40) ' this is a test ' """ ret = ' ' + ' '.join(strings) + ' ' assert len(string_utils.strip_ansi_sequences(ret)) < width x = 0 while len(string_utils.strip_ansi_sequences(ret)) < width: spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)] where = spaces[x] before = ret[:where] after = ret[where:] ret = before + padding + after x += 1 if x >= len(spaces): x = 0 return ret def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str: """ Justifies a string. >>> justify_string_by_chunk("This is a test", 40) 'This is a test' >>> justify_string_by_chunk("This is a test", 20) 'This is a test' """ assert len(string_utils.strip_ansi_sequences(string)) <= width padding = padding[0] first, *rest, last = string.split() w = width - ( len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last)) ) ret = first + distribute_strings(rest, width=w, padding=padding) + last return ret def justify_string( string: str, *, width: int = 80, alignment: str = "c", padding: str = " " ) -> str: """Justify a string. >>> justify_string('This is another test', width=40, alignment='c') ' This is another test ' >>> justify_string('This is another test', width=40, alignment='l') 'This is another test ' >>> justify_string('This is another test', width=40, alignment='r') ' This is another test' >>> justify_string('This is another test', width=40, alignment='j') 'This is another test' """ alignment = alignment[0] padding = padding[0] while len(string_utils.strip_ansi_sequences(string)) < width: if alignment == "l": string += padding elif alignment == "r": string = padding + string elif alignment == "j": return justify_string_by_chunk(string, width=width, padding=padding) elif alignment == "c": if len(string) % 2 == 0: string += padding else: string = padding + string else: raise ValueError return string def justify_text(text: str, *, width: int = 80, alignment: str = "c", indent_by: int = 0) -> str: """ Justifies text optionally with initial indentation. >>> justify_text('This is a test of the emergency broadcast system. This is only a test.', ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE 'This is a test of the emergency\\nbroadcast system. This is only a test.' """ retval = '' indent = '' if indent_by > 0: indent += ' ' * indent_by line = indent for word in text.split(): if ( len(string_utils.strip_ansi_sequences(line)) + len(string_utils.strip_ansi_sequences(word)) ) > width: line = line[1:] line = justify_string(line, width=width, alignment=alignment) retval = retval + '\n' + line line = indent line = line + ' ' + word if len(string_utils.strip_ansi_sequences(line)) > 0: if alignment != 'j': retval += "\n" + justify_string(line[1:], width=width, alignment=alignment) else: retval += "\n" + line[1:] return retval[1:] def generate_padded_columns(text: List[str]) -> Generator: max_width: Dict[int, int] = defaultdict(int) for line in text: for pos, word in enumerate(line.split()): max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word))) for line in text: out = "" for pos, word in enumerate(line.split()): width = max_width[pos] word = justify_string(word, width=width, alignment='l') out += f'{word} ' yield out def wrap_string(text: str, n: int) -> str: chunks = text.split() out = '' width = 0 for chunk in chunks: if width + len(string_utils.strip_ansi_sequences(chunk)) > n: out += '\n' width = 0 out += chunk + ' ' width += len(string_utils.strip_ansi_sequences(chunk)) + 1 return out class Indenter(contextlib.AbstractContextManager): """ with Indenter(pad_count = 8) as i: i.print('test') with i: i.print('-ing') with i: i.print('1, 2, 3') """ def __init__( self, *, pad_prefix: Optional[str] = None, pad_char: str = ' ', pad_count: int = 4, ): self.level = -1 if pad_prefix is not None: self.pad_prefix = pad_prefix else: self.pad_prefix = '' self.padding = pad_char * pad_count def __enter__(self): self.level += 1 return self def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]: self.level -= 1 if self.level < -1: self.level = -1 return False def print(self, *arg, **kwargs): text = string_utils.sprintf(*arg, **kwargs) print(self.pad_prefix + self.padding * self.level + text, end='') def header( title: str, *, width: Optional[int] = None, align: Optional[str] = None, style: Optional[str] = 'solid', ): """ Returns a nice header line with a title. >>> header('title', width=60, style='ascii') '----[ title ]-----------------------------------------------' """ if not width: width = get_console_rows_columns().columns if not align: align = 'left' if not style: style = 'ascii' text_len = len(string_utils.strip_ansi_sequences(title)) if align == 'left': left = 4 right = width - (left + text_len + 4) elif align == 'right': right = 4 left = width - (right + text_len + 4) else: left = int((width - (text_len + 4)) / 2) right = left while left + text_len + 4 + right < width: right += 1 if style == 'solid': line_char = '━' begin = '' end = '' elif style == 'dashed': line_char = '┅' begin = '' end = '' else: line_char = '-' begin = '[' end = ']' return line_char * left + begin + ' ' + title + ' ' + end + line_char * right def box( title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = '' ) -> str: assert width > 4 if text is not None: text = justify_text(text, width=width - 4, alignment='l') return preformatted_box(title, text, width=width, color=color) def preformatted_box( title: Optional[str] = None, text: Optional[str] = None, *, width=80, color: str = '' ) -> str: assert width > 4 ret = '' if color == '': rset = '' else: rset = reset() w = width - 2 ret += color + '╭' + '─' * w + '╮' + rset + '\n' if title is not None: ret += ( color + '│' + rset + justify_string(title, width=w, alignment='c') + color + '│' + rset + '\n' ) ret += color + '│' + ' ' * w + '│' + rset + '\n' if text is not None: for line in text.split('\n'): tw = len(string_utils.strip_ansi_sequences(line)) assert tw <= w ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n' ret += color + '╰' + '─' * w + '╯' + rset + '\n' return ret def print_box( title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = '' ) -> None: """Draws a box with nice rounded corners. >>> print_box('Title', 'This is text', width=30) ╭────────────────────────────╮ │ Title │ │ │ │ This is text │ ╰────────────────────────────╯ >>> print_box(None, 'OK', width=6) ╭────╮ │ OK │ ╰────╯ """ print(preformatted_box(title, text, width=width, color=color), end='') if __name__ == '__main__': import doctest doctest.testmod()