flexablelib-components 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1059 @@
1
+ """
2
+ Display components: badges, tags, statuses, alerts, links, breadcrumbs,
3
+ empty and error states, spinners and skeletons, code with highlighting,
4
+ Markdown and HTML views, detail lists, timelines, steppers, comments,
5
+ attachments, KPI stats and line charts.
6
+ """
7
+
8
+ import html.parser
9
+ import math
10
+ import re
11
+
12
+ from flexablelib import _frames as _fx
13
+ from flexablelib.text_width import wrap_to_width
14
+
15
+ from .base import _Themed, frame
16
+
17
+ __all__ = ['Badge', 'Tag', 'StatusBadge', 'Alert', 'Link', 'Breadcrumbs',
18
+ 'EmptyState', 'ErrorPanel', 'Spinner', 'Skeleton', 'Code',
19
+ 'highlight', 'MarkdownView', 'HtmlView', 'DetailView', 'Timeline',
20
+ 'Stepper', 'CommentList', 'AttachmentList', 'Stat', 'LineChart',
21
+ 'human_size']
22
+
23
+
24
+ def _wrap(text, width):
25
+ lines = []
26
+ for paragraph in str(text).split('\n'):
27
+ lines.extend(wrap_to_width(paragraph, max(1, width)) or [''])
28
+ return lines
29
+
30
+
31
+ class Badge(_Themed):
32
+ """A short label on a coloured background: ``kind`` is a theme kind
33
+ (``'info'``, ``'success'``, ``'warning'``, ``'error'``) or ``color``."""
34
+
35
+ def __init__(self, text, kind='info', color=None, **kwargs):
36
+ super(Badge, self).__init__(**kwargs)
37
+ self.text = str(text)
38
+ self.kind = kind
39
+ self.color = color
40
+
41
+ def natural_width(self):
42
+ return _fx.visible_width(self.text) + 2
43
+
44
+ def inline(self):
45
+ if not self._active():
46
+ return '[' + self.text + ']'
47
+ color = self.color or self.theme.kind(self.kind)
48
+ return self.paint(' ' + self.text + ' ', 'bold black on ' + color)
49
+
50
+ def draw(self, width, height):
51
+ return [self.inline()]
52
+
53
+
54
+ class Tag(Badge):
55
+ """A tag in a colour; ``(etiqueta)`` without colours."""
56
+
57
+ def inline(self):
58
+ color = self.color or self.theme.kind(self.kind)
59
+ if not self._active():
60
+ return '(' + self.text + ')'
61
+ return self.paint('▐', color) + self.paint(self.text,
62
+ 'black on ' + color) + \
63
+ self.paint('▌', color)
64
+
65
+
66
+ class StatusBadge(Badge):
67
+ """
68
+ A status from ``options`` -- ``{value: (label, kind_or_colour)}`` -- with
69
+ a coloured dot: ``● Pagado``.
70
+ """
71
+
72
+ def __init__(self, value, options=None, **kwargs):
73
+ self.options = options or {}
74
+ self.value = value
75
+ label, kind = self.options.get(value, (str(value), 'info'))
76
+ super(StatusBadge, self).__init__(label, kind=kind, **kwargs)
77
+
78
+ def inline(self):
79
+ kind = self.kind
80
+ color = self.color or getattr(self.theme, kind, None) or kind
81
+ dot = self.paint('●', color) if self._active() else '*'
82
+ return dot + ' ' + self.text
83
+
84
+
85
+ class Alert(_Themed):
86
+ """
87
+ A message box: ``kind`` sets the icon and colour; ``title`` is optional.
88
+ ``dismissible=True`` adds a close hint and lets Escape hide it.
89
+ """
90
+
91
+ ICONS = {'info': 'ℹ', 'success': '✔', 'warning': '⚠', 'error': '✖'}
92
+
93
+ def __init__(self, message, kind='info', title=None, dismissible=False,
94
+ **kwargs):
95
+ super(Alert, self).__init__(**kwargs)
96
+ self.message = message
97
+ self.kind = kind
98
+ self.title = title
99
+ self.dismissible = bool(dismissible)
100
+ self.focusable = self.dismissible
101
+ self.visible = True
102
+
103
+ def natural_width(self):
104
+ return min(80, max(_fx.visible_width(line) for line in
105
+ str(self.message).split('\n')) + 6)
106
+
107
+ def on_key(self, key):
108
+ if self.dismissible and str(key) in ('escape', 'enter', 'x'):
109
+ self.visible = False
110
+ self.refresh()
111
+ return True
112
+ return False
113
+
114
+ def draw(self, width, height):
115
+ if not self.visible:
116
+ return []
117
+ color = self.theme.kind(self.kind)
118
+ icon = self.ICONS.get(self.kind, '•')
119
+ inner = max(1, width - 4)
120
+ lines = []
121
+ if self.title:
122
+ lines.append(self.paint(icon + ' ' + self.title, 'bold ' + color))
123
+ lines.extend(_wrap(self.message, inner))
124
+ else:
125
+ body = _wrap(self.message, inner - 2)
126
+ lines.append(self.paint(icon, 'bold ' + color) + ' ' + body[0])
127
+ lines.extend(' ' + line for line in body[1:])
128
+ lines = [' ' + line for line in lines]
129
+ footer = 'esc' if self.dismissible and self.focused else None
130
+ return frame(lines, width, None, color, self.paint, footer=footer)
131
+
132
+
133
+ class Link(_Themed):
134
+ """
135
+ A hyperlink. On terminals that support OSC 8 it is clickable; the URL is
136
+ shown after the text when ``show_url=True`` or colours are off.
137
+ """
138
+
139
+ focusable = True
140
+
141
+ def __init__(self, text, url=None, show_url=False, on_open=None,
142
+ **kwargs):
143
+ super(Link, self).__init__(**kwargs)
144
+ self.text = text
145
+ self.url = url or text
146
+ self.show_url = bool(show_url)
147
+ self.on_open = on_open
148
+
149
+ def natural_width(self):
150
+ return _fx.visible_width(self.text) + (len(self.url) + 3
151
+ if self.show_url else 0)
152
+
153
+ def inline(self):
154
+ if not self._active():
155
+ return '{0} <{1}>'.format(self.text, self.url) \
156
+ if self.text != self.url else self.url
157
+ spec = 'underline ' + (self.theme.accent if self.focused else
158
+ self.theme.info)
159
+ text = '\x1b]8;;{0}\x1b\\{1}\x1b]8;;\x1b\\'.format(
160
+ self.url, self.paint(self.text, spec))
161
+ if self.show_url:
162
+ text += ' ' + self.paint(self.url, self.theme.muted)
163
+ return text
164
+
165
+ def on_key(self, key):
166
+ if str(key) == 'enter' and self.on_open is not None:
167
+ self.on_open(self.url)
168
+ return True
169
+ return False
170
+
171
+ def draw(self, width, height):
172
+ return [self.inline()]
173
+
174
+
175
+ class Breadcrumbs(_Themed):
176
+ """``Inicio › Ventas › Pedido 1281``: the last item is the current one;
177
+ long trails collapse in the middle to fit."""
178
+
179
+ def __init__(self, items, separator=' › ', **kwargs):
180
+ super(Breadcrumbs, self).__init__(**kwargs)
181
+ self.items = [str(i) for i in items]
182
+ self.separator = separator
183
+
184
+ def natural_width(self):
185
+ return _fx.visible_width(self.separator.join(self.items))
186
+
187
+ def draw(self, width, height):
188
+ items = list(self.items)
189
+ fits = lambda xs: _fx.visible_width( # noqa: E731
190
+ self.separator.join(xs)) <= width
191
+ if not fits(items) and len(items) > 2:
192
+ # Keep the first and as many of the last as fit, '…' between.
193
+ tail = [items[-1]]
194
+ for item in reversed(items[1:-1]):
195
+ if not fits([items[0], '…', item] + tail):
196
+ break
197
+ tail.insert(0, item)
198
+ items = [items[0], '…'] + tail
199
+ parts = [self.paint(i, self.theme.muted) for i in items[:-1]]
200
+ if items:
201
+ parts.append(self.paint(items[-1], 'bold'))
202
+ return [self.paint(self.separator, self.theme.muted).join(parts)]
203
+
204
+
205
+ class EmptyState(_Themed):
206
+ """A centred icon, title and hint for a view with nothing to show."""
207
+
208
+ expand = True
209
+
210
+ def __init__(self, title='Sin datos', message=None, icon='∅',
211
+ action=None, **kwargs):
212
+ super(EmptyState, self).__init__(**kwargs)
213
+ self.title = title
214
+ self.message = message
215
+ self.icon = icon
216
+ self.action = action
217
+
218
+ def natural_width(self):
219
+ return max(_fx.visible_width(self.title),
220
+ _fx.visible_width(self.message or '')) + 4
221
+
222
+ def draw(self, width, height):
223
+ lines = [self.paint(self.icon, self.theme.muted), '',
224
+ self.paint(self.title, 'bold')]
225
+ if self.message:
226
+ lines.extend(self.paint(line, self.theme.muted)
227
+ for line in _wrap(self.message, width - 4))
228
+ if self.action:
229
+ lines.extend(['', self.paint(self.action, self.theme.accent)])
230
+ top = max(0, ((height or len(lines)) - len(lines)) // 2)
231
+ return [''] * top + [_fx.fit_line(line, width, 'c') for line in lines]
232
+
233
+
234
+ class ErrorPanel(EmptyState):
235
+ """An error screen: big code, title, message and what to do next."""
236
+
237
+ def __init__(self, code=None, title='Algo salió mal', message=None,
238
+ hint=None, **kwargs):
239
+ super(ErrorPanel, self).__init__(title=title, message=message,
240
+ icon='✖', action=hint, **kwargs)
241
+ self.code = code
242
+
243
+ def draw(self, width, height):
244
+ lines = super(ErrorPanel, self).draw(width, None)
245
+ lines[0] = _fx.fit_line(self.paint(
246
+ '✖ ' + (str(self.code) if self.code is not None else 'Error'),
247
+ 'bold ' + self.theme.error), width, 'c')
248
+ top = max(0, ((height or len(lines)) - len(lines)) // 2)
249
+ return [''] * top + lines
250
+
251
+
252
+ class Spinner(_Themed):
253
+ """
254
+ An animated spinner with a message; each ``render`` (or ``tick()``)
255
+ advances it. ``style`` is ``'dots'``, ``'line'``, ``'arc'`` or
256
+ ``'bounce'``.
257
+ """
258
+
259
+ STYLES = {'dots': '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏', 'line': '-\\|/', 'arc': '◜◠◝◞◡◟',
260
+ 'bounce': '⠁⠂⠄⠂'}
261
+
262
+ def __init__(self, message='', style='dots', **kwargs):
263
+ super(Spinner, self).__init__(**kwargs)
264
+ self.message = message
265
+ self.frames = self.STYLES.get(style, style)
266
+ self.index = 0
267
+
268
+ def natural_width(self):
269
+ return _fx.visible_width(self.message) + 2
270
+
271
+ def tick(self):
272
+ self.index = (self.index + 1) % len(self.frames)
273
+
274
+ def draw(self, width, height):
275
+ glyph = self.frames[self.index % len(self.frames)]
276
+ self.tick()
277
+ return [self.paint(glyph, self.theme.accent) +
278
+ (' ' + self.message if self.message else '')]
279
+
280
+
281
+ class Skeleton(_Themed):
282
+ """Grey placeholder bars while content loads (``lines`` of them)."""
283
+
284
+ def __init__(self, lines=3, **kwargs):
285
+ super(Skeleton, self).__init__(**kwargs)
286
+ self.lines = max(1, int(lines))
287
+
288
+ def draw(self, width, height):
289
+ out = []
290
+ for i in range(self.lines):
291
+ w = width if i < self.lines - 1 else max(1, width * 3 // 5)
292
+ out.append(self.paint('░' * w, self.theme.muted))
293
+ return out
294
+
295
+
296
+ # +---------------------------------------------------------------------------+
297
+ # Code
298
+
299
+ _KEYWORDS = {
300
+ 'python': 'and as assert async await break class continue def del elif '
301
+ 'else except False finally for from global if import in is '
302
+ 'lambda None nonlocal not or pass raise return True try while '
303
+ 'with yield self',
304
+ 'javascript': 'async await break case catch class const continue default '
305
+ 'delete do else export extends false finally for function '
306
+ 'if import in instanceof let new null return super switch '
307
+ 'this throw true try typeof undefined var void while yield',
308
+ 'sql': 'select from where and or not insert into values update set delete '
309
+ 'create table alter drop index join left right inner outer on as '
310
+ 'group by order having limit offset null is in like between case '
311
+ 'when then else end distinct union all primary key references '
312
+ 'returning with',
313
+ 'bash': 'if then else elif fi for in do done while case esac function '
314
+ 'return export local echo exit',
315
+ 'json': 'true false null',
316
+ }
317
+ _KEYWORDS['typescript'] = _KEYWORDS['javascript'] + ' interface type enum ' \
318
+ 'implements private public readonly'
319
+ _KEYWORDS['js'] = _KEYWORDS['javascript']
320
+ _KEYWORDS['ts'] = _KEYWORDS['typescript']
321
+ _KEYWORDS['py'] = _KEYWORDS['python']
322
+ _KEYWORDS['sh'] = _KEYWORDS['bash']
323
+ _COMMENTS = {'python': '#', 'py': '#', 'bash': '#', 'sh': '#', 'sql': '--',
324
+ 'javascript': '//', 'js': '//', 'typescript': '//', 'ts': '//'}
325
+ _TOKEN = re.compile(r'''(?P<str>"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`[^`]*`?)'''
326
+ r'''|(?P<num>\b\d+(?:\.\d+)?\b)|(?P<word>[A-Za-z_]\w*)''')
327
+
328
+
329
+ def highlight(line, language, painter, theme):
330
+ """One line of code with strings, numbers, keywords and comments
331
+ coloured. Unknown languages come back as they are."""
332
+ lang = (language or '').lower()
333
+ if lang not in _KEYWORDS:
334
+ return line
335
+ keywords = set(_KEYWORDS[lang].split())
336
+ sql = lang == 'sql'
337
+ comment = _COMMENTS.get(lang)
338
+ code, rest = line, ''
339
+ if comment:
340
+ # A comment marker outside strings starts the comment.
341
+ in_str = None
342
+ for i, ch in enumerate(line):
343
+ if in_str:
344
+ if ch == in_str and line[i - 1:i] != '\\':
345
+ in_str = None
346
+ elif ch in '"\'`':
347
+ in_str = ch
348
+ elif line.startswith(comment, i):
349
+ code, rest = line[:i], line[i:]
350
+ break
351
+ out, pos = [], 0
352
+ for match in _TOKEN.finditer(code):
353
+ out.append(code[pos:match.start()])
354
+ text = match.group(0)
355
+ if match.group('str'):
356
+ out.append(painter(text, theme.success))
357
+ elif match.group('num'):
358
+ out.append(painter(text, theme.warning))
359
+ elif (text.lower() if sql else text) in keywords:
360
+ out.append(painter(text, 'bold ' + theme.accent))
361
+ else:
362
+ out.append(text)
363
+ pos = match.end()
364
+ out.append(code[pos:])
365
+ if rest:
366
+ out.append(painter(rest, theme.muted))
367
+ return ''.join(out)
368
+
369
+
370
+ class Code(_Themed):
371
+ """
372
+ Source code with line numbers and highlighting for ``language``
373
+ (python, javascript/typescript, sql, bash, json). Long lines are cut;
374
+ ``title`` frames it.
375
+ """
376
+
377
+ def __init__(self, code, language=None, line_numbers=True, title=None,
378
+ start=1, **kwargs):
379
+ super(Code, self).__init__(**kwargs)
380
+ self.code = code.rstrip('\n')
381
+ self.language = language
382
+ self.line_numbers = bool(line_numbers)
383
+ self.title = title
384
+ self.start = start
385
+
386
+ def natural_width(self):
387
+ lines = self.code.split('\n')
388
+ gutter = len(str(len(lines) + self.start)) + 3 \
389
+ if self.line_numbers else 0
390
+ return max(_fx.visible_width(line) for line in lines) + gutter + \
391
+ (4 if self.title else 0)
392
+
393
+ def draw(self, width, height):
394
+ lines = self.code.split('\n')
395
+ digits = len(str(len(lines) + self.start - 1))
396
+ inner = width - 4 if self.title else width
397
+ out = []
398
+ for i, line in enumerate(lines):
399
+ text = highlight(line.expandtabs(4), self.language, self.paint,
400
+ self.theme)
401
+ if self.line_numbers:
402
+ num = self.paint('{0:>{1}} │ '.format(i + self.start, digits),
403
+ self.theme.muted)
404
+ text = num + text
405
+ out.append(_fx.fit_line(text, max(1, inner)))
406
+ if self.title:
407
+ return frame([' ' + line for line in out], width, self.title,
408
+ self.theme.border, self.paint)
409
+ return out
410
+
411
+
412
+ # +---------------------------------------------------------------------------+
413
+ # Markdown and HTML
414
+
415
+ _INLINE = [
416
+ (re.compile(r'`([^`]+)`'), 'code'),
417
+ (re.compile(r'\*\*(.+?)\*\*|__(.+?)__'), 'bold'),
418
+ (re.compile(r'(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|'
419
+ r'(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])'), 'italic'),
420
+ (re.compile(r'~~(.+?)~~'), 'strike'),
421
+ (re.compile(r'\[([^\]]+)\]\(([^)]+)\)'), 'link'),
422
+ ]
423
+
424
+
425
+ class MarkdownView(_Themed):
426
+ """
427
+ Markdown drawn for the terminal: headings, emphasis, inline and fenced
428
+ code (highlighted), quotes, ordered / unordered / task lists, rules,
429
+ links and pipe tables (as flexablelib tables), wrapped to the width.
430
+ """
431
+
432
+ def __init__(self, text, **kwargs):
433
+ super(MarkdownView, self).__init__(**kwargs)
434
+ self.text = text
435
+
436
+ def natural_width(self):
437
+ return min(100, max((_fx.visible_width(line) for line in
438
+ self.text.split('\n')), default=0))
439
+
440
+ def inline(self, text):
441
+ # Each piece is rendered and parked behind a placeholder, so the
442
+ # escape sequences it adds (which contain '[') are never matched by
443
+ # the patterns that run after it.
444
+ store = []
445
+ token = re.compile('\x00(\\d+)\x00')
446
+
447
+ def restore(value):
448
+ return token.sub(lambda m: store[int(m.group(1))], value)
449
+
450
+ def keep(rendered):
451
+ store.append(rendered)
452
+ return '\x00{0}\x00'.format(len(store) - 1)
453
+
454
+ def render(kind, match):
455
+ inner = restore(next(g for g in match.groups() if g is not None))
456
+ if kind == 'code':
457
+ return self.paint(inner, self.theme.warning) if \
458
+ self._active() else '`' + inner + '`'
459
+ if kind == 'bold':
460
+ return self.paint(inner, 'bold')
461
+ if kind == 'italic':
462
+ return self.paint(inner, 'italic')
463
+ if kind == 'strike':
464
+ return self.paint(inner, 'dim')
465
+ url = match.group(2)
466
+ if not self._active():
467
+ return '{0} <{1}>'.format(inner, url)
468
+ return '\x1b]8;;{0}\x1b\\{1}\x1b]8;;\x1b\\'.format(
469
+ url, self.paint(inner, 'underline ' + self.theme.info))
470
+ for pattern, kind in _INLINE:
471
+ text = pattern.sub(lambda m, k=kind: keep(render(k, m)), text)
472
+ return restore(text)
473
+
474
+ def _table(self, rows, width):
475
+ from flexablelib import Table
476
+ cells = [[c.strip() for c in row.strip().strip('|').split('|')]
477
+ for row in rows]
478
+ header, body = cells[0], [r for r in cells[2:]]
479
+ table = Table(headers=header)
480
+ for row in body:
481
+ table.add_row((row + [''] * len(header))[:len(header)])
482
+ table.style_name = 'rounded'
483
+ table.use_colors = self._active()
484
+ table.max_width = width
485
+ return table.compose().split('\n')
486
+
487
+ def draw(self, width, height):
488
+ out = []
489
+ lines = self.text.split('\n')
490
+ i = 0
491
+ while i < len(lines):
492
+ line = lines[i]
493
+ stripped = line.strip()
494
+ if stripped.startswith('```'):
495
+ lang = stripped[3:].strip() or None
496
+ block = []
497
+ i += 1
498
+ while i < len(lines) and not lines[i].strip().startswith('```'):
499
+ block.append(lines[i])
500
+ i += 1
501
+ i += 1
502
+ bar = self.paint('│ ', self.theme.muted)
503
+ out.extend(bar + _fx.fit_line(highlight(
504
+ b, lang, self.paint, self.theme), max(1, width - 2))
505
+ for b in block)
506
+ continue
507
+ if stripped.startswith('|') and i + 1 < len(lines) and \
508
+ re.match(r'^\s*\|?\s*:?-+', lines[i + 1]):
509
+ rows = []
510
+ while i < len(lines) and lines[i].strip().startswith('|'):
511
+ rows.append(lines[i])
512
+ i += 1
513
+ out.extend(self._table(rows, width))
514
+ continue
515
+ i += 1
516
+ heading = re.match(r'^(#{1,6})\s+(.*)', stripped)
517
+ if heading:
518
+ level = len(heading.group(1))
519
+ text = self.inline(heading.group(2))
520
+ if out and out[-1] != '':
521
+ out.append('')
522
+ if level == 1:
523
+ out.append(self.paint(text.upper() if not self._active()
524
+ else text, 'bold underline ' +
525
+ self.theme.accent))
526
+ elif level == 2:
527
+ out.append(self.paint(text, 'bold ' + self.theme.accent))
528
+ else:
529
+ out.append(self.paint(text, 'bold'))
530
+ continue
531
+ if re.match(r'^(-{3,}|\*{3,}|_{3,})$', stripped):
532
+ out.append(self.paint('─' * width, self.theme.muted))
533
+ continue
534
+ if stripped.startswith('>'):
535
+ bar = self.paint('▎ ', self.theme.muted)
536
+ for part in _wrap(self.inline(stripped.lstrip('> ')),
537
+ width - 2):
538
+ out.append(bar + self.paint(part, 'italic'))
539
+ continue
540
+ item = re.match(r'^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)',
541
+ line)
542
+ if item:
543
+ indent = len(item.group(1).expandtabs(4)) // 2 * 2
544
+ marker = item.group(2)
545
+ if marker in '-*+':
546
+ marker = '•' if self._active() else '-'
547
+ task = item.group(3)
548
+ if task:
549
+ done = task.strip()[1] in 'xX'
550
+ marker = ('☑' if done else '☐') if self._active() else \
551
+ ('[x]' if done else '[ ]')
552
+ prefix = ' ' * indent + marker + ' '
553
+ body = _wrap(self.inline(item.group(4)),
554
+ width - len(prefix))
555
+ out.append(prefix + body[0])
556
+ out.extend(' ' * len(prefix) + b for b in body[1:])
557
+ continue
558
+ if not stripped:
559
+ if out and out[-1] != '':
560
+ out.append('')
561
+ continue
562
+ out.extend(_wrap(self.inline(stripped), width))
563
+ while out and out[-1] == '':
564
+ out.pop()
565
+ return out
566
+
567
+
568
+ class _HtmlToMarkdown(html.parser.HTMLParser):
569
+ BLOCK = {'p', 'div', 'section', 'article', 'header', 'footer', 'ul',
570
+ 'ol', 'table', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4',
571
+ 'h5', 'h6', 'hr', 'br', 'tr'}
572
+
573
+ def __init__(self):
574
+ html.parser.HTMLParser.__init__(self, convert_charrefs=True)
575
+ self.out = []
576
+ self.lists = []
577
+ self.href = None
578
+ self.pre = False
579
+ self.row = None
580
+ self.rows = []
581
+ self.skip = 0
582
+
583
+ def emit(self, text):
584
+ self.out.append(text)
585
+
586
+ def handle_starttag(self, tag, attrs):
587
+ attrs = dict(attrs)
588
+ if tag in ('script', 'style', 'head'):
589
+ self.skip += 1
590
+ elif tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
591
+ self.emit('\n\n' + '#' * int(tag[1]) + ' ')
592
+ elif tag in ('p', 'div', 'section', 'article'):
593
+ self.emit('\n\n')
594
+ elif tag == 'br':
595
+ self.emit('\n')
596
+ elif tag in ('strong', 'b'):
597
+ self.emit('**')
598
+ elif tag in ('em', 'i'):
599
+ self.emit('*')
600
+ elif tag == 'code' and not self.pre:
601
+ self.emit('`')
602
+ elif tag == 'pre':
603
+ self.pre = True
604
+ self.emit('\n```\n')
605
+ elif tag == 'a':
606
+ self.href = attrs.get('href')
607
+ self.emit('[')
608
+ elif tag in ('ul', 'ol'):
609
+ self.lists.append([tag, 0])
610
+ self.emit('\n')
611
+ elif tag == 'li':
612
+ depth = max(0, len(self.lists) - 1)
613
+ kind = self.lists[-1] if self.lists else ['ul', 0]
614
+ kind[1] += 1
615
+ marker = '{0}.'.format(kind[1]) if kind[0] == 'ol' else '-'
616
+ self.emit('\n' + ' ' * depth + marker + ' ')
617
+ elif tag == 'blockquote':
618
+ self.emit('\n\n> ')
619
+ elif tag == 'hr':
620
+ self.emit('\n\n---\n\n')
621
+ elif tag == 'tr':
622
+ self.row = []
623
+ elif tag in ('td', 'th') and self.row is not None:
624
+ self.row.append('')
625
+
626
+ def handle_endtag(self, tag):
627
+ if tag in ('script', 'style', 'head'):
628
+ self.skip = max(0, self.skip - 1)
629
+ elif tag in ('strong', 'b'):
630
+ self.emit('**')
631
+ elif tag in ('em', 'i'):
632
+ self.emit('*')
633
+ elif tag == 'code' and not self.pre:
634
+ self.emit('`')
635
+ elif tag == 'pre':
636
+ self.pre = False
637
+ self.emit('\n```\n')
638
+ elif tag == 'a':
639
+ self.emit('](' + (self.href or '') + ')')
640
+ self.href = None
641
+ elif tag in ('ul', 'ol') and self.lists:
642
+ self.lists.pop()
643
+ self.emit('\n')
644
+ elif tag == 'tr' and self.row is not None:
645
+ self.rows.append(self.row)
646
+ self.row = None
647
+ elif tag == 'table' and self.rows:
648
+ width = max(len(r) for r in self.rows)
649
+ rows = [r + [''] * (width - len(r)) for r in self.rows]
650
+ lines = ['| ' + ' | '.join(rows[0]) + ' |',
651
+ '|' + '---|' * width]
652
+ lines.extend('| ' + ' | '.join(r) + ' |' for r in rows[1:])
653
+ self.emit('\n\n' + '\n'.join(lines) + '\n\n')
654
+ self.rows = []
655
+ elif tag in ('p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
656
+ 'blockquote'):
657
+ self.emit('\n\n')
658
+
659
+ def handle_data(self, data):
660
+ if self.skip:
661
+ return
662
+ if self.row is not None and self.row:
663
+ self.row[-1] += data.strip()
664
+ return
665
+ if self.row is not None:
666
+ return
667
+ if not self.pre:
668
+ data = re.sub(r'\s+', ' ', data)
669
+ self.emit(data)
670
+
671
+
672
+ class HtmlView(MarkdownView):
673
+ """HTML drawn like :class:`MarkdownView` (headings, emphasis, links,
674
+ lists, quotes, code, tables); scripts and styles are dropped."""
675
+
676
+ def __init__(self, html_text, **kwargs):
677
+ parser = _HtmlToMarkdown()
678
+ parser.feed(html_text)
679
+ parser.close()
680
+ text = re.sub(r'\n{3,}', '\n\n', ''.join(parser.out)).strip()
681
+ text = '\n'.join(line.strip() if not line.startswith(' ') else
682
+ line.rstrip() for line in text.split('\n'))
683
+ super(HtmlView, self).__init__(text, **kwargs)
684
+ self.html = html_text
685
+
686
+
687
+ # +---------------------------------------------------------------------------+
688
+ # Records
689
+
690
+ class DetailView(_Themed):
691
+ """
692
+ Labelled values in aligned columns -- a record's detail. ``fields`` is a
693
+ mapping or ``(label, value)`` pairs; a label starting with ``#`` is a
694
+ section heading. ``columns=2`` lays the pairs out in two columns.
695
+ """
696
+
697
+ def __init__(self, fields, columns=1, label_color=None, **kwargs):
698
+ super(DetailView, self).__init__(**kwargs)
699
+ self.fields = list(fields.items()) if isinstance(fields, dict) \
700
+ else list(fields)
701
+ self.columns = max(1, int(columns))
702
+ self.label_color = label_color
703
+
704
+ def natural_width(self):
705
+ widths = [_fx.visible_width(str(k)) + _fx.visible_width(str(v)) + 2
706
+ for k, v in self.fields]
707
+ return min(100, max(widths or [10]) * self.columns +
708
+ 3 * (self.columns - 1))
709
+
710
+ def _block(self, fields, width):
711
+ label_w = min(width // 2, max([_fx.visible_width(str(k))
712
+ for k, _ in fields
713
+ if not str(k).startswith('#')] or [0]))
714
+ out = []
715
+ for label, value in fields:
716
+ label = str(label)
717
+ if label.startswith('#'):
718
+ if out:
719
+ out.append('')
720
+ out.append(self.paint(label.lstrip('# '), 'bold ' +
721
+ self.theme.accent))
722
+ continue
723
+ body = _wrap('' if value is None else value,
724
+ max(1, width - label_w - 2))
725
+ head = self.paint(_fx.fit_line(label, label_w),
726
+ self.label_color or self.theme.muted)
727
+ out.append(head + ' ' + body[0])
728
+ out.extend(' ' * (label_w + 2) + b for b in body[1:])
729
+ return out
730
+
731
+ def draw(self, width, height):
732
+ if self.columns == 1:
733
+ return self._block(self.fields, width)
734
+ from .base import join_columns
735
+ per = int(math.ceil(len(self.fields) / float(self.columns)))
736
+ col_w = max(1, (width - 3 * (self.columns - 1)) // self.columns)
737
+ blocks = [self._block(self.fields[i * per:(i + 1) * per], col_w)
738
+ for i in range(self.columns)]
739
+ return join_columns(blocks, [col_w] * self.columns, gap=3)
740
+
741
+
742
+ class Timeline(_Themed):
743
+ """
744
+ Events down a line: ``(when, title, detail)`` or dicts with ``when``,
745
+ ``title``, ``detail``, ``kind`` / ``status``. Also a status history: give
746
+ ``kind`` per event (``'success'``, ``'error'`` ...).
747
+ """
748
+
749
+ def __init__(self, events, **kwargs):
750
+ super(Timeline, self).__init__(**kwargs)
751
+ self.events = [self._event(e) for e in events]
752
+
753
+ @staticmethod
754
+ def _event(event):
755
+ if isinstance(event, dict):
756
+ return (str(event.get('when', '')), str(event.get('title', '')),
757
+ event.get('detail'), event.get('kind',
758
+ event.get('status')))
759
+ event = tuple(event) + (None, None)
760
+ return (str(event[0]), str(event[1]), event[2], event[3])
761
+
762
+ def natural_width(self):
763
+ return max([_fx.visible_width(w) + _fx.visible_width(t) + 5
764
+ for w, t, _, _ in self.events] or [10])
765
+
766
+ def draw(self, width, height):
767
+ when_w = max([_fx.visible_width(w) for w, _, _, _ in self.events] or
768
+ [0])
769
+ out = []
770
+ for i, (when, title, detail, kind) in enumerate(self.events):
771
+ color = self.theme.kind(kind) if kind else self.theme.accent
772
+ dot = self.paint('●', color) if self._active() else 'o'
773
+ line = '│' if i < len(self.events) - 1 else ' '
774
+ stem = self.paint(line, self.theme.muted) if self._active() \
775
+ else ('|' if line != ' ' else ' ')
776
+ out.append(self.paint(_fx.fit_line(when, when_w, 'r'),
777
+ self.theme.muted) + ' ' + dot + ' ' +
778
+ self.paint(title, 'bold'))
779
+ body_w = max(1, width - when_w - 3)
780
+ for part in (_wrap(detail, body_w) if detail else []):
781
+ out.append(' ' * when_w + ' ' + stem + ' ' + part)
782
+ if i < len(self.events) - 1:
783
+ out.append(' ' * when_w + ' ' + stem)
784
+ return out
785
+
786
+
787
+ class Stepper(_Themed):
788
+ """
789
+ Progress through steps: ``✔ Datos ─── ● Pago ─── ○ Envío``. ``current``
790
+ is the active step's index; ``layout='vertical'`` lists them with their
791
+ ``descriptions``.
792
+ """
793
+
794
+ def __init__(self, steps, current=0, layout='horizontal',
795
+ descriptions=None, failed=None, **kwargs):
796
+ super(Stepper, self).__init__(**kwargs)
797
+ self.steps = [str(s) for s in steps]
798
+ self.current = current
799
+ self.layout = layout
800
+ self.descriptions = descriptions or {}
801
+ self.failed = failed
802
+
803
+ def natural_width(self):
804
+ return sum(_fx.visible_width(s) + 6 for s in self.steps)
805
+
806
+ def _mark(self, i):
807
+ if i == self.failed:
808
+ return self.paint('✖', self.theme.error) if self._active() \
809
+ else 'x', self.theme.error
810
+ if i < self.current:
811
+ return (self.paint('✔', self.theme.success) if self._active()
812
+ else 'v'), None
813
+ if i == self.current:
814
+ return (self.paint('●', self.theme.accent) if self._active()
815
+ else '>'), 'bold ' + self.theme.accent
816
+ return (self.paint('○', self.theme.muted) if self._active()
817
+ else 'o'), self.theme.muted
818
+
819
+ def draw(self, width, height):
820
+ if self.layout == 'vertical':
821
+ out = []
822
+ for i, step in enumerate(self.steps):
823
+ mark, spec = self._mark(i)
824
+ out.append(mark + ' ' + self.paint(step, spec))
825
+ desc = self.descriptions.get(i) or self.descriptions.get(step)
826
+ if desc:
827
+ out.extend('│ ' + self.paint(d, self.theme.muted)
828
+ for d in _wrap(desc, width - 2))
829
+ if i < len(self.steps) - 1:
830
+ out.append(self.paint('│', self.theme.muted))
831
+ return out
832
+ labels = []
833
+ for i, step in enumerate(self.steps):
834
+ mark, spec = self._mark(i)
835
+ labels.append(mark + ' ' + self.paint(step, spec))
836
+ used = sum(_fx.visible_width(l) for l in labels)
837
+ gaps = len(labels) - 1
838
+ if gaps and used + 3 * gaps <= width:
839
+ seg = max(3, min(8, (width - used) // gaps - 2))
840
+ joiner = ' ' + self.paint('─' * seg, self.theme.muted) + ' '
841
+ return [joiner.join(labels)]
842
+ # Too narrow: only the current step, with its number.
843
+ mark, spec = self._mark(self.current)
844
+ return ['{0}/{1} '.format(self.current + 1, len(self.steps)) + mark +
845
+ ' ' + self.paint(self.steps[self.current], spec)]
846
+
847
+
848
+ class CommentList(_Themed):
849
+ """
850
+ A conversation: dicts with ``author``, ``when``, ``text`` and optional
851
+ ``replies`` (indented below).
852
+ """
853
+
854
+ def __init__(self, comments, **kwargs):
855
+ super(CommentList, self).__init__(**kwargs)
856
+ self.comments = list(comments)
857
+
858
+ def _one(self, comment, width, depth, out):
859
+ pad = ' ' * depth + ('│ ' if depth else '')
860
+ avail = max(1, width - _fx.visible_width(pad))
861
+ head = self.paint(str(comment.get('author', '')), 'bold ' +
862
+ self.theme.accent)
863
+ if comment.get('when'):
864
+ head += ' ' + self.paint('· ' + str(comment['when']),
865
+ self.theme.muted)
866
+ out.append(pad + head)
867
+ out.extend(pad + line for line in _wrap(comment.get('text', ''),
868
+ avail))
869
+ for reply in comment.get('replies', []):
870
+ self._one(reply, width, depth + 1, out)
871
+
872
+ def draw(self, width, height):
873
+ out = []
874
+ for i, comment in enumerate(self.comments):
875
+ if i:
876
+ out.append('')
877
+ self._one(comment, width, 0, out)
878
+ return out
879
+
880
+
881
+ def human_size(size):
882
+ """``1536`` -> ``'1.5 KB'``."""
883
+ size = float(size or 0)
884
+ for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
885
+ if size < 1024 or unit == 'TB':
886
+ return ('{0:.0f} {1}' if unit == 'B' else '{0:.1f} {1}').format(
887
+ size, unit)
888
+ size /= 1024.0
889
+
890
+
891
+ class AttachmentList(_Themed):
892
+ """Files with an icon by type, name and size: ``(name, size)`` pairs or
893
+ dicts with ``name`` and ``size``."""
894
+
895
+ ICONS = {'pdf': '📕', 'xlsx': '📗', 'xls': '📗', 'csv': '📗', 'doc': '📘',
896
+ 'docx': '📘', 'png': '🖼', 'jpg': '🖼', 'jpeg': '🖼', 'gif': '🖼',
897
+ 'zip': '📦', 'txt': '📄', 'json': '📄', 'md': '📄'}
898
+
899
+ def __init__(self, files, **kwargs):
900
+ super(AttachmentList, self).__init__(**kwargs)
901
+ self.files = [(f.get('name'), f.get('size')) if isinstance(f, dict)
902
+ else tuple(f) for f in files]
903
+
904
+ def draw(self, width, height):
905
+ out = []
906
+ for name, size in self.files:
907
+ ext = str(name).rsplit('.', 1)[-1].lower() if '.' in str(name) \
908
+ else ''
909
+ icon = self.ICONS.get(ext, '📎') if self._active() else '-'
910
+ right = human_size(size) if size is not None else ''
911
+ room = max(1, width - _fx.visible_width(icon) - len(right) - 2)
912
+ out.append(icon + ' ' + _fx.fit_line(_fx.truncate(
913
+ str(name), room, '…'), room) + ' ' +
914
+ self.paint(right, self.theme.muted))
915
+ return out
916
+
917
+
918
+ class Stat(_Themed):
919
+ """
920
+ A KPI: label, big value, change against before (``delta``, a number or
921
+ text; positive is green unless ``invert=True``) and an optional trend
922
+ sparkline (``trend`` values).
923
+ """
924
+
925
+ def __init__(self, label, value, delta=None, trend=None, invert=False,
926
+ suffix='', **kwargs):
927
+ super(Stat, self).__init__(**kwargs)
928
+ self.label = label
929
+ self.value = value
930
+ self.delta = delta
931
+ self.trend = list(trend or [])
932
+ self.invert = bool(invert)
933
+ self.suffix = suffix
934
+
935
+ def natural_width(self):
936
+ return max(_fx.visible_width(str(self.label)),
937
+ _fx.visible_width(str(self.value)) + 10, 18)
938
+
939
+ def draw(self, width, height):
940
+ out = [self.paint(str(self.label), self.theme.muted)]
941
+ value = self.paint(str(self.value), 'bold')
942
+ if self.delta is not None:
943
+ number = self.delta
944
+ if isinstance(number, str):
945
+ try:
946
+ number = float(number.strip('%+ '))
947
+ if self.delta.strip().startswith('-'):
948
+ number = -abs(number)
949
+ except ValueError:
950
+ number = 0
951
+ good = (number >= 0) != self.invert
952
+ arrow = '▲' if number > 0 else ('▼' if number < 0 else '■')
953
+ text = self.delta if isinstance(self.delta, str) else \
954
+ '{0:+g}{1}'.format(self.delta, self.suffix)
955
+ value += ' ' + self.paint(arrow + ' ' + text.lstrip('+-')
956
+ if number else arrow + ' ' + text,
957
+ self.theme.success if good else
958
+ self.theme.error)
959
+ out.append(value)
960
+ if self.trend:
961
+ sparks = '▁▂▃▄▅▆▇█'
962
+ data = self.trend[-width:]
963
+ low, high = min(data), max(data)
964
+ span = (high - low) or 1
965
+ out.append(self.paint(''.join(
966
+ sparks[int((v - low) / span * 7)] for v in data),
967
+ self.theme.accent))
968
+ return out
969
+
970
+
971
+ class LineChart(_Themed):
972
+ """
973
+ Lines on a braille canvas (2x4 dots per cell) with a y axis and x
974
+ labels. ``series`` maps names to value lists (or is one list);
975
+ ``height`` is in rows. Each series takes a colour from ``colors``.
976
+ """
977
+
978
+ COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red']
979
+
980
+ def __init__(self, series, height=8, labels=None, colors=None,
981
+ y_format='{0:g}', legend=True, **kwargs):
982
+ super(LineChart, self).__init__(**kwargs)
983
+ if not isinstance(series, dict):
984
+ series = {'': list(series)}
985
+ self.series = {k: list(v) for k, v in series.items()}
986
+ self.height = max(2, int(height))
987
+ self.labels = labels
988
+ self.colors = list(colors or self.COLORS)
989
+ self.y_format = y_format
990
+ self.legend = bool(legend)
991
+
992
+ def natural_width(self):
993
+ return max(30, max(len(v) for v in self.series.values()) + 8)
994
+
995
+ def draw(self, width, height):
996
+ rows = self.height if height is None else max(2, min(self.height,
997
+ height - 2))
998
+ values = [v for s in self.series.values() for v in s]
999
+ if not values:
1000
+ return ['']
1001
+ low, high = min(values), max(values)
1002
+ if high == low:
1003
+ high = low + 1
1004
+ labels = [self.y_format.format(high), self.y_format.format(low)]
1005
+ axis_w = max(len(l) for l in labels) + 1
1006
+ cols = max(2, width - axis_w - 1)
1007
+ dots_w, dots_h = cols * 2, rows * 4
1008
+ cells = [[0] * cols for _ in range(rows)]
1009
+ owner = [[None] * cols for _ in range(rows)]
1010
+ bits = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))
1011
+ for n, (name, data) in enumerate(self.series.items()):
1012
+ if len(data) < 2:
1013
+ data = data * 2
1014
+ prev = None
1015
+ for x in range(dots_w):
1016
+ pos = x * (len(data) - 1) / float(max(1, dots_w - 1))
1017
+ i = int(pos)
1018
+ frac = pos - i
1019
+ v = data[i] if i + 1 >= len(data) else \
1020
+ data[i] + (data[i + 1] - data[i]) * frac
1021
+ y = int(round((high - v) / (high - low) * (dots_h - 1)))
1022
+ span = [y] if prev is None else range(min(prev, y),
1023
+ max(prev, y) + 1)
1024
+ for yy in span:
1025
+ r, c = yy // 4, x // 2
1026
+ cells[r][c] |= bits[yy % 4][x % 2]
1027
+ owner[r][c] = n
1028
+ prev = y
1029
+ out = []
1030
+ for r in range(rows):
1031
+ label = labels[0] if r == 0 else (labels[1] if r == rows - 1
1032
+ else '')
1033
+ line = []
1034
+ for c in range(cols):
1035
+ bitsum = cells[r][c]
1036
+ if not bitsum:
1037
+ line.append(' ')
1038
+ continue
1039
+ char = chr(0x2800 + bitsum)
1040
+ color = self.colors[owner[r][c] % len(self.colors)]
1041
+ line.append(self.paint(char, color))
1042
+ out.append(self.paint(_fx.fit_line(label, axis_w - 1, 'r'),
1043
+ self.theme.muted) + ' ' +
1044
+ self.paint('┤' if label else '│', self.theme.muted) +
1045
+ ''.join(line))
1046
+ axis = ' ' * axis_w + self.paint('└' + '─' * cols, self.theme.muted)
1047
+ out.append(axis)
1048
+ if self.labels:
1049
+ first, last = str(self.labels[0]), str(self.labels[-1])
1050
+ gap = max(1, cols - len(first) - len(last))
1051
+ out.append(' ' * (axis_w + 1) + self.paint(
1052
+ first + ' ' * gap + last, self.theme.muted))
1053
+ if self.legend and len(self.series) > 1:
1054
+ parts = [self.paint('━━', self.colors[n % len(self.colors)]) +
1055
+ ' ' + name for n, name in enumerate(self.series)]
1056
+ out.append(' ' * (axis_w + 1) + ' '.join(parts))
1057
+ return out
1058
+
1059
+