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.
- flexablelib_components/__init__.py +64 -0
- flexablelib_components/base.py +329 -0
- flexablelib_components/data.py +931 -0
- flexablelib_components/dates.py +828 -0
- flexablelib_components/display.py +1059 -0
- flexablelib_components/inputs.py +1543 -0
- flexablelib_components/navigation.py +1257 -0
- flexablelib_components/overlays.py +234 -0
- flexablelib_components/py.typed +0 -0
- flexablelib_components-3.3.0.dist-info/METADATA +44 -0
- flexablelib_components-3.3.0.dist-info/RECORD +14 -0
- flexablelib_components-3.3.0.dist-info/WHEEL +5 -0
- flexablelib_components-3.3.0.dist-info/licenses/LICENSE +21 -0
- flexablelib_components-3.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|