2 # -*- coding: utf-8 -*-
4 """Utilities for dealing with "text"."""
11 from collections import defaultdict
12 from dataclasses import dataclass
13 from typing import Dict, Generator, List, Literal, Optional, Tuple
16 from ansi import fg, reset
18 logger = logging.getLogger(__file__)
29 def get_console_rows_columns() -> RowsColumns:
30 """Returns the number of rows/columns on the current console."""
32 from exec_utils import cmd
39 except Exception as e:
41 raise Exception('Can\'t determine console size?!') from e
42 return RowsColumns(int(rows), int(columns))
50 fgcolor=fg("school bus yellow"),
55 """Draws a progress graph."""
57 percent = current / total
58 ret = "\r" if redraw else "\n"
67 print(bar, end=ret, flush=True, file=sys.stderr)
75 fgcolor=fg("school bus yellow"),
80 """Returns a string containing a bar graph.
82 >>> bar_graph(0.5, fgcolor='', reset_seq='')
83 '[███████████████████████████████████ ] 50.0%'
87 if percentage < 0.0 or percentage > 1.0:
88 raise ValueError(percentage)
90 text = f"{percentage*100.0:2.1f}%"
93 whole_width = math.floor(percentage * width)
94 if whole_width == width:
97 elif whole_width == 0 and percentage > 0.0:
100 remainder_width = (percentage * width) % 1
101 part_width = math.floor(remainder_width * 8)
102 part_char = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][part_width]
108 + " " * (width - whole_width - 1)
116 def sparkline(numbers: List[float]) -> Tuple[float, float, str]:
118 Makes a "sparkline" little inline histogram graph. Auto scales.
120 >>> sparkline([1, 2, 3, 5, 10, 3, 5, 7])
123 >>> sparkline([104, 99, 93, 96, 82, 77, 85, 73])
124 (73, 104, '█▇▆▆▃▂▄▁')
127 _bar = '▁▂▃▄▅▆▇█' # Unicode: 9601, 9602, 9603, 9604, 9605, 9606, 9607, 9608
130 min_num, max_num = min(numbers), max(numbers)
131 span = max_num - min_num
133 _bar[min([barcount - 1, int((n - min_num) / span * barcount)])] for n in numbers
135 return min_num, max_num, sline
138 def distribute_strings(
145 Distributes strings into a line for justified text.
147 >>> distribute_strings(['this', 'is', 'a', 'test'], width=40)
151 ret = ' ' + ' '.join(strings) + ' '
152 assert len(string_utils.strip_ansi_sequences(ret)) < width
154 while len(string_utils.strip_ansi_sequences(ret)) < width:
155 spaces = [m.start() for m in re.finditer(r' ([^ ]|$)', ret)]
159 ret = before + padding + after
166 def justify_string_by_chunk(string: str, width: int = 80, padding: str = " ") -> str:
170 >>> justify_string_by_chunk("This is a test", 40)
172 >>> justify_string_by_chunk("This is a test", 20)
176 assert len(string_utils.strip_ansi_sequences(string)) <= width
178 first, *rest, last = string.split()
180 len(string_utils.strip_ansi_sequences(first)) + len(string_utils.strip_ansi_sequences(last))
182 ret = first + distribute_strings(rest, width=w, padding=padding) + last
187 string: str, *, width: int = 80, alignment: str = "c", padding: str = " "
191 >>> justify_string('This is another test', width=40, alignment='c')
192 ' This is another test '
193 >>> justify_string('This is another test', width=40, alignment='l')
194 'This is another test '
195 >>> justify_string('This is another test', width=40, alignment='r')
196 ' This is another test'
197 >>> justify_string('This is another test', width=40, alignment='j')
198 'This is another test'
201 alignment = alignment[0]
203 while len(string_utils.strip_ansi_sequences(string)) < width:
206 elif alignment == "r":
207 string = padding + string
208 elif alignment == "j":
209 return justify_string_by_chunk(string, width=width, padding=padding)
210 elif alignment == "c":
211 if len(string) % 2 == 0:
214 string = padding + string
220 def justify_text(text: str, *, width: int = 80, alignment: str = "c") -> str:
224 >>> justify_text('This is a test of the emergency broadcast system. This is only a test.',
225 ... width=40, alignment='j') #doctest: +NORMALIZE_WHITESPACE
226 'This is a test of the emergency\\nbroadcast system. This is only a test.'
231 for word in text.split():
233 len(string_utils.strip_ansi_sequences(line))
234 + len(string_utils.strip_ansi_sequences(word))
237 line = justify_string(line, width=width, alignment=alignment)
238 retval = retval + "\n" + line
240 line = line + " " + word
241 if len(string_utils.strip_ansi_sequences(line)) > 0:
242 retval += "\n" + line[1:]
246 def generate_padded_columns(text: List[str]) -> Generator:
247 max_width: Dict[int, int] = defaultdict(int)
249 for pos, word in enumerate(line.split()):
250 max_width[pos] = max(max_width[pos], len(string_utils.strip_ansi_sequences(word)))
254 for pos, word in enumerate(line.split()):
255 width = max_width[pos]
256 word = justify_string(word, width=width, alignment='l')
261 def wrap_string(text: str, n: int) -> str:
262 chunks = text.split()
266 if width + len(string_utils.strip_ansi_sequences(chunk)) > n:
270 width += len(string_utils.strip_ansi_sequences(chunk)) + 1
274 class Indenter(contextlib.AbstractContextManager):
276 with Indenter(pad_count = 8) as i:
288 pad_prefix: Optional[str] = None,
293 if pad_prefix is not None:
294 self.pad_prefix = pad_prefix
297 self.padding = pad_char * pad_count
303 def __exit__(self, exc_type, exc_value, exc_tb) -> Literal[False]:
309 def print(self, *arg, **kwargs):
310 text = string_utils.sprintf(*arg, **kwargs)
311 print(self.pad_prefix + self.padding * self.level + text, end='')
314 def header(title: str, *, width: int = 80, color: str = ''):
316 Returns a nice header line with a title.
318 >>> header('title', width=60, color='')
319 '----[ title ]-----------------------------------------------'
323 w -= len(string_utils.strip_ansi_sequences(title)) + 4
326 right = (w - 4) * '-'
327 if color != '' and color is not None:
331 return f'{left}[ {color}{title}{r} ]{right}'
337 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
346 ret += color + '╭' + '─' * w + '╮' + rset + '\n'
347 if title is not None:
352 + justify_string(title, width=w, alignment='c')
358 ret += color + '│' + ' ' * w + '│' + rset + '\n'
360 for line in justify_text(text, width=w - 2, alignment='l').split('\n'):
361 tw = len(string_utils.strip_ansi_sequences(line))
363 ret += color + '│ ' + rset + line + ' ' * (w - tw - 2) + color + ' │' + rset + '\n'
364 ret += color + '╰' + '─' * w + '╯' + rset + '\n'
369 title: Optional[str] = None, text: Optional[str] = None, *, width: int = 80, color: str = ''
371 """Draws a box with nice rounded corners.
373 >>> print_box('Title', 'This is text', width=30)
374 ╭────────────────────────────╮
378 ╰────────────────────────────╯
380 >>> print_box(None, 'OK', width=6)
386 print(box(title, text, width=width, color=color), end='')
389 if __name__ == '__main__':