flexablelib-tui 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_tui/__init__.py +27 -0
- flexablelib_tui/app.py +640 -0
- flexablelib_tui/keys.py +340 -0
- flexablelib_tui/py.typed +0 -0
- flexablelib_tui/screen.py +276 -0
- flexablelib_tui/terminal.py +239 -0
- flexablelib_tui/widgets.py +624 -0
- flexablelib_tui-3.3.0.dist-info/METADATA +41 -0
- flexablelib_tui-3.3.0.dist-info/RECORD +12 -0
- flexablelib_tui-3.3.0.dist-info/WHEEL +5 -0
- flexablelib_tui-3.3.0.dist-info/licenses/LICENSE +21 -0
- flexablelib_tui-3.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TERMINAL WIDGETS
|
|
3
|
+
|
|
4
|
+
Small renderables for dashboards (``pip install flexablelib[tui]``), built
|
|
5
|
+
to sit in a :class:`~flexablelib.Grid`
|
|
6
|
+
or a :class:`~flexablelib.Card` next to tables:
|
|
7
|
+
|
|
8
|
+
``ProgressBar`` ``Ventas ███████████▍░░░░░░ 64%``
|
|
9
|
+
``Sparkline`` ``▁▂▄▆█▇▅▃``
|
|
10
|
+
``BarChart`` one labelled horizontal bar per value
|
|
11
|
+
``StatusBar`` a full-width bar with left / centre / right text
|
|
12
|
+
``KeyHints`` ``q Salir F1 Ayuda`` key chips
|
|
13
|
+
``LogView`` the tail of a log, levels coloured
|
|
14
|
+
``Text`` aligned, wrapped, coloured text
|
|
15
|
+
``Rule`` a horizontal rule with an optional title
|
|
16
|
+
|
|
17
|
+
Every widget has ``render(width=None, height=None)`` (``None`` width: the
|
|
18
|
+
terminal's), ``natural_width()`` and ``str(widget)``. Widths count visible
|
|
19
|
+
columns: ANSI escapes are zero wide, emoji and CJK two. Colour follows
|
|
20
|
+
``use_colors``: ``None`` means only on a terminal, honouring ``NO_COLOR`` and
|
|
21
|
+
``FORCE_COLOR``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import builtins
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from flexablelib.colors import RESET, parse_spec, supports_color
|
|
28
|
+
from flexablelib.layout import _align, _fit_line, render_width
|
|
29
|
+
from flexablelib import _frames as _fx
|
|
30
|
+
from flexablelib.text_width import (
|
|
31
|
+
pad_to_width,
|
|
32
|
+
strip_ansi,
|
|
33
|
+
wrap_to_width,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = ['ProgressBar', 'Sparkline', 'BarChart', 'StatusBar', 'KeyHints',
|
|
37
|
+
'LogView', 'Text', 'Rule']
|
|
38
|
+
|
|
39
|
+
_EIGHTHS = ' ▏▎▍▌▋▊▉'
|
|
40
|
+
_SPARKS = '▁▂▃▄▅▆▇█'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Widget(object):
|
|
44
|
+
"""Shared colour handling and the render protocol."""
|
|
45
|
+
|
|
46
|
+
use_colors = None
|
|
47
|
+
|
|
48
|
+
def _active(self):
|
|
49
|
+
if self.use_colors is None:
|
|
50
|
+
return supports_color()
|
|
51
|
+
return bool(self.use_colors)
|
|
52
|
+
|
|
53
|
+
def _paint(self, text, spec):
|
|
54
|
+
if not text or not spec or not self._active():
|
|
55
|
+
return text
|
|
56
|
+
prefix = parse_spec(spec)
|
|
57
|
+
if not prefix:
|
|
58
|
+
return text
|
|
59
|
+
return prefix + text.replace(RESET, RESET + prefix) + RESET
|
|
60
|
+
|
|
61
|
+
def _width(self, width):
|
|
62
|
+
if width is None:
|
|
63
|
+
width = getattr(self, 'width', None)
|
|
64
|
+
if width is None:
|
|
65
|
+
return min(self.natural_width(), render_width(None))
|
|
66
|
+
return render_width(width)
|
|
67
|
+
|
|
68
|
+
def _finish(self, lines, width, height):
|
|
69
|
+
"""Clip or pad to ``height`` lines; strip colour when it is off."""
|
|
70
|
+
if height is not None:
|
|
71
|
+
height = max(0, int(height))
|
|
72
|
+
lines = lines[:height] + [' ' * width] * (height - len(lines))
|
|
73
|
+
text = '\n'.join(lines)
|
|
74
|
+
if not self._active():
|
|
75
|
+
text = strip_ansi(text)
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
def __str__(self):
|
|
79
|
+
return self.render()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_number(name, value):
|
|
83
|
+
try:
|
|
84
|
+
return float(value)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
raise TypeError('{0} must be a number, got {1!r}'.format(name, value))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _threshold_color(fraction, color, thresholds):
|
|
90
|
+
"""The colour for ``fraction``: the last threshold it reaches."""
|
|
91
|
+
if not thresholds:
|
|
92
|
+
return color
|
|
93
|
+
pairs = sorted(thresholds.items() if isinstance(thresholds, dict)
|
|
94
|
+
else thresholds)
|
|
95
|
+
chosen = color
|
|
96
|
+
for limit, spec in pairs:
|
|
97
|
+
if fraction >= float(limit):
|
|
98
|
+
chosen = spec
|
|
99
|
+
return chosen
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProgressBar(_Widget):
|
|
103
|
+
"""
|
|
104
|
+
A horizontal bar for a value out of a total.
|
|
105
|
+
|
|
106
|
+
>>> print(ProgressBar(64, label='Ventas', use_colors=False).render(30))
|
|
107
|
+
Ventas ██████████████▏░░░░░░░░ 64%
|
|
108
|
+
|
|
109
|
+
``thresholds`` changes the colour as the bar fills: ``{0.7: 'yellow',
|
|
110
|
+
0.9: 'red'}``. With the default ``█`` fill the end of the bar is drawn in
|
|
111
|
+
eighths (``▏▎▍▌▋▊▉``) so small changes show. ``value_format`` replaces the
|
|
112
|
+
percentage, e.g. ``'{value:.0f}/{total:.0f} GB'``. ``label_width`` and
|
|
113
|
+
``value_width`` pad both ends so a stack of bars lines up.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, value, total=100, label=None, width=None,
|
|
117
|
+
show_percent=True, value_format=None, color='green',
|
|
118
|
+
empty_color='bright_black', label_color=None,
|
|
119
|
+
thresholds=None, fill_char='█', empty_char='░',
|
|
120
|
+
label_width=None, value_width=None, use_colors=None):
|
|
121
|
+
self.value = _check_number('value', value)
|
|
122
|
+
self.total = _check_number('total', total)
|
|
123
|
+
if self.total <= 0:
|
|
124
|
+
raise ValueError('total must be > 0')
|
|
125
|
+
self.label = label
|
|
126
|
+
self.width = width
|
|
127
|
+
self.show_percent = show_percent
|
|
128
|
+
self.value_format = value_format
|
|
129
|
+
self.color = color
|
|
130
|
+
self.empty_color = empty_color
|
|
131
|
+
self.label_color = label_color
|
|
132
|
+
self.thresholds = thresholds
|
|
133
|
+
if not fill_char or not empty_char:
|
|
134
|
+
raise ValueError('fill_char and empty_char cannot be empty')
|
|
135
|
+
self.fill_char = fill_char
|
|
136
|
+
self.empty_char = empty_char
|
|
137
|
+
self.label_width = label_width
|
|
138
|
+
self.value_width = value_width
|
|
139
|
+
self.use_colors = use_colors
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def fraction(self):
|
|
143
|
+
"""The filled share, clamped to ``0..1``."""
|
|
144
|
+
return max(0.0, min(1.0, self.value / self.total))
|
|
145
|
+
|
|
146
|
+
def _suffix(self):
|
|
147
|
+
if self.value_format:
|
|
148
|
+
text = self.value_format.format(
|
|
149
|
+
value=self.value, total=self.total,
|
|
150
|
+
percent=self.fraction * 100)
|
|
151
|
+
elif self.show_percent:
|
|
152
|
+
text = '{0:>3.0f}%'.format(self.fraction * 100)
|
|
153
|
+
else:
|
|
154
|
+
text = ''
|
|
155
|
+
if self.value_width is not None:
|
|
156
|
+
text = pad_to_width(text, int(self.value_width), 'r')
|
|
157
|
+
return text
|
|
158
|
+
|
|
159
|
+
def _label(self):
|
|
160
|
+
if self.label is None:
|
|
161
|
+
return ''
|
|
162
|
+
if self.label_width is not None:
|
|
163
|
+
return _fit_line(str(self.label), int(self.label_width))
|
|
164
|
+
return str(self.label)
|
|
165
|
+
|
|
166
|
+
def natural_width(self):
|
|
167
|
+
label, suffix = self._label(), self._suffix()
|
|
168
|
+
return (_fx.visible_width(label) + (1 if label else 0) + 20 +
|
|
169
|
+
(1 + _fx.visible_width(suffix) if suffix else 0))
|
|
170
|
+
|
|
171
|
+
def bar(self, width):
|
|
172
|
+
"""Just the bar, ``width`` columns, coloured."""
|
|
173
|
+
width = max(0, int(width))
|
|
174
|
+
cells = self.fraction * width
|
|
175
|
+
full = int(cells)
|
|
176
|
+
rest = cells - full
|
|
177
|
+
partial = ''
|
|
178
|
+
if self.fill_char == '█' and full < width:
|
|
179
|
+
eighth = int(round(rest * 8))
|
|
180
|
+
if eighth == 8:
|
|
181
|
+
full += 1
|
|
182
|
+
elif eighth:
|
|
183
|
+
partial = _EIGHTHS[eighth]
|
|
184
|
+
filled = self.fill_char * full + partial
|
|
185
|
+
empty = self.empty_char * (width - full - (1 if partial else 0))
|
|
186
|
+
color = _threshold_color(self.fraction, self.color, self.thresholds)
|
|
187
|
+
return self._paint(filled, color) + self._paint(empty,
|
|
188
|
+
self.empty_color)
|
|
189
|
+
|
|
190
|
+
def render(self, width=None, height=None):
|
|
191
|
+
width = self._width(width)
|
|
192
|
+
label, suffix = self._label(), self._suffix()
|
|
193
|
+
head = (self._paint(label, self.label_color) + ' ') if label else ''
|
|
194
|
+
tail = (' ' + suffix) if suffix else ''
|
|
195
|
+
room = width - _fx.visible_width(head) - _fx.visible_width(tail)
|
|
196
|
+
if room < 1:
|
|
197
|
+
# Too narrow for label and value: keep the bar.
|
|
198
|
+
head, tail, room = '', '', width
|
|
199
|
+
line = head + self.bar(room) + tail
|
|
200
|
+
return self._finish([line], width, height)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Sparkline(_Widget):
|
|
204
|
+
"""
|
|
205
|
+
A one-line chart of a series: ``▁▂▄▆█▇▅▃``.
|
|
206
|
+
|
|
207
|
+
When the series is longer than the width the most recent values are
|
|
208
|
+
shown. ``low`` / ``high`` fix the scale (default: the data's range).
|
|
209
|
+
``label`` goes in front and ``show_last`` appends the latest value.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, values, width=None, color=None, low=None, high=None,
|
|
213
|
+
label=None, show_last=False, value_format='{0:g}',
|
|
214
|
+
chars=_SPARKS, label_color=None, label_width=None,
|
|
215
|
+
use_colors=None):
|
|
216
|
+
self.values = [_check_number('values', v) for v in values]
|
|
217
|
+
self.width = width
|
|
218
|
+
self.color = color
|
|
219
|
+
self.low = low
|
|
220
|
+
self.high = high
|
|
221
|
+
self.label = label
|
|
222
|
+
self.show_last = show_last
|
|
223
|
+
self.value_format = value_format
|
|
224
|
+
if not chars:
|
|
225
|
+
raise ValueError('chars cannot be empty')
|
|
226
|
+
self.chars = chars
|
|
227
|
+
self.label_color = label_color
|
|
228
|
+
self.label_width = label_width
|
|
229
|
+
self.use_colors = use_colors
|
|
230
|
+
|
|
231
|
+
def _parts(self):
|
|
232
|
+
label = '' if self.label is None else str(self.label)
|
|
233
|
+
if label and self.label_width is not None:
|
|
234
|
+
label = _fit_line(label, int(self.label_width))
|
|
235
|
+
head = (label + ' ') if label else ''
|
|
236
|
+
tail = ''
|
|
237
|
+
if self.show_last and self.values:
|
|
238
|
+
tail = ' ' + self.value_format.format(self.values[-1])
|
|
239
|
+
return head, tail
|
|
240
|
+
|
|
241
|
+
def natural_width(self):
|
|
242
|
+
head, tail = self._parts()
|
|
243
|
+
return _fx.visible_width(head) + max(1, len(self.values)) + \
|
|
244
|
+
_fx.visible_width(tail)
|
|
245
|
+
|
|
246
|
+
def line(self, width):
|
|
247
|
+
"""Just the chart, at most ``width`` columns."""
|
|
248
|
+
values = self.values[-width:] if width > 0 else []
|
|
249
|
+
if not values:
|
|
250
|
+
return ''
|
|
251
|
+
low = min(values) if self.low is None else float(self.low)
|
|
252
|
+
high = max(values) if self.high is None else float(self.high)
|
|
253
|
+
steps = len(self.chars) - 1
|
|
254
|
+
out = []
|
|
255
|
+
for v in values:
|
|
256
|
+
if high == low:
|
|
257
|
+
index = steps // 2 if high else 0
|
|
258
|
+
else:
|
|
259
|
+
index = int(round((min(max(v, low), high) - low) /
|
|
260
|
+
(high - low) * steps))
|
|
261
|
+
out.append(self.chars[index])
|
|
262
|
+
return self._paint(''.join(out), self.color)
|
|
263
|
+
|
|
264
|
+
def render(self, width=None, height=None):
|
|
265
|
+
width = self._width(width)
|
|
266
|
+
head, tail = self._parts()
|
|
267
|
+
room = width - _fx.visible_width(head) - _fx.visible_width(tail)
|
|
268
|
+
if room < 1:
|
|
269
|
+
head, tail, room = '', '', width
|
|
270
|
+
line = self._paint(head, self.label_color) + self.line(room) + tail
|
|
271
|
+
return self._finish([line], width, height)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class BarChart(_Widget):
|
|
275
|
+
"""
|
|
276
|
+
Labelled horizontal bars, one per value.
|
|
277
|
+
|
|
278
|
+
>>> print(BarChart({'Norte': 120, 'Sur': 80}, use_colors=False).render(30))
|
|
279
|
+
Norte ███████████████████ 120
|
|
280
|
+
Sur ████████████▋ 80
|
|
281
|
+
|
|
282
|
+
``colors`` is one spec or ``{label: spec}``; ``sort=True`` puts the
|
|
283
|
+
largest first; ``max`` fixes the scale.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def __init__(self, data, width=None, color='cyan', colors=None,
|
|
287
|
+
show_values=True, value_format='{0:,.0f}', sort=False,
|
|
288
|
+
max=None, label_color=None, fill_char='█', use_colors=None):
|
|
289
|
+
if isinstance(data, dict):
|
|
290
|
+
pairs = list(data.items())
|
|
291
|
+
else:
|
|
292
|
+
pairs = [tuple(item) for item in data]
|
|
293
|
+
self.data = [(str(k), _check_number('value', v)) for k, v in pairs]
|
|
294
|
+
if any(v < 0 for _, v in self.data):
|
|
295
|
+
raise ValueError('BarChart values must be >= 0')
|
|
296
|
+
self.width = width
|
|
297
|
+
self.color = color
|
|
298
|
+
self.colors = colors or {}
|
|
299
|
+
self.show_values = show_values
|
|
300
|
+
self.value_format = value_format
|
|
301
|
+
self.sort = sort
|
|
302
|
+
self.max = max
|
|
303
|
+
self.label_color = label_color
|
|
304
|
+
self.fill_char = fill_char
|
|
305
|
+
self.use_colors = use_colors
|
|
306
|
+
|
|
307
|
+
def _rows(self):
|
|
308
|
+
rows = list(self.data)
|
|
309
|
+
if self.sort:
|
|
310
|
+
rows.sort(key=lambda kv: kv[1], reverse=True)
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
def natural_width(self):
|
|
314
|
+
rows = self._rows()
|
|
315
|
+
if not rows:
|
|
316
|
+
return 1
|
|
317
|
+
label_w = max(_fx.visible_width(k) for k, _ in rows)
|
|
318
|
+
value_w = max(_fx.visible_width(self.value_format.format(v))
|
|
319
|
+
for _, v in rows) if self.show_values else 0
|
|
320
|
+
return label_w + 1 + 24 + (1 + value_w if value_w else 0)
|
|
321
|
+
|
|
322
|
+
def render(self, width=None, height=None):
|
|
323
|
+
width = self._width(width)
|
|
324
|
+
rows = self._rows()
|
|
325
|
+
if not rows:
|
|
326
|
+
return self._finish([''], width, height)
|
|
327
|
+
label_w = min(max(_fx.visible_width(k) for k, _ in rows),
|
|
328
|
+
max(1, width // 3))
|
|
329
|
+
values = [self.value_format.format(v) for _, v in rows]
|
|
330
|
+
value_w = max(_fx.visible_width(v) for v in values) \
|
|
331
|
+
if self.show_values else 0
|
|
332
|
+
bar_w = width - label_w - 1 - (value_w + 1 if value_w else 0)
|
|
333
|
+
if bar_w < 1:
|
|
334
|
+
bar_w, value_w = max(1, width - label_w - 1), 0
|
|
335
|
+
top = float(self.max) if self.max is not None else \
|
|
336
|
+
builtins.max(v for _, v in rows)
|
|
337
|
+
lines = []
|
|
338
|
+
for (label, value), shown in zip(rows, values):
|
|
339
|
+
spec = self.colors.get(label, self.color) \
|
|
340
|
+
if isinstance(self.colors, dict) else self.colors
|
|
341
|
+
bar = ProgressBar(min(value, top) if top else 0, top or 1,
|
|
342
|
+
show_percent=False, color=spec,
|
|
343
|
+
fill_char=self.fill_char, empty_char=' ',
|
|
344
|
+
empty_color=None, use_colors=self._active())
|
|
345
|
+
line = (self._paint(_fit_line(label, label_w), self.label_color) +
|
|
346
|
+
' ' + bar.bar(bar_w))
|
|
347
|
+
if value_w:
|
|
348
|
+
line += ' ' + pad_to_width(shown, value_w, 'r')
|
|
349
|
+
lines.append(line)
|
|
350
|
+
return self._finish(lines, width, height)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class StatusBar(_Widget):
|
|
354
|
+
"""
|
|
355
|
+
One full-width line with ``left``, ``center`` and ``right`` text on a
|
|
356
|
+
background, like an editor's status line.
|
|
357
|
+
|
|
358
|
+
When the parts do not fit, the centre goes first, then the left part is
|
|
359
|
+
cut with ``…``.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
def __init__(self, left='', center='', right='', color='black on cyan',
|
|
363
|
+
width=None, padding=1, use_colors=None):
|
|
364
|
+
self.left = str(left or '')
|
|
365
|
+
self.center = str(center or '')
|
|
366
|
+
self.right = str(right or '')
|
|
367
|
+
self.color = color
|
|
368
|
+
self.width = width
|
|
369
|
+
self.padding = max(0, int(padding))
|
|
370
|
+
self.use_colors = use_colors
|
|
371
|
+
|
|
372
|
+
def natural_width(self):
|
|
373
|
+
parts = [p for p in (self.left, self.center, self.right) if p]
|
|
374
|
+
return sum(_fx.visible_width(p) for p in parts) + \
|
|
375
|
+
2 * max(0, len(parts) - 1) + 2 * self.padding
|
|
376
|
+
|
|
377
|
+
def render(self, width=None, height=None):
|
|
378
|
+
width = self._width(width)
|
|
379
|
+
pad = ' ' * min(self.padding, max(0, (width - 1) // 2))
|
|
380
|
+
inner = width - 2 * len(pad)
|
|
381
|
+
left, center, right = self.left, self.center, self.right
|
|
382
|
+
lw, cw, rw = (_fx.visible_width(left), _fx.visible_width(center),
|
|
383
|
+
_fx.visible_width(right))
|
|
384
|
+
if lw + cw + rw + (2 if center else 0) + (2 if left and right else 0)\
|
|
385
|
+
> inner:
|
|
386
|
+
center, cw = '', 0
|
|
387
|
+
if lw + rw + (1 if left and right else 0) > inner:
|
|
388
|
+
rw = min(rw, inner)
|
|
389
|
+
right = _fx.truncate(right, rw, '…') if rw < \
|
|
390
|
+
_fx.visible_width(right) else right
|
|
391
|
+
lw = max(0, inner - rw - (1 if rw else 0))
|
|
392
|
+
left = _fx.truncate(left, lw, '…') if lw else ''
|
|
393
|
+
lw = _fx.visible_width(left)
|
|
394
|
+
body = left
|
|
395
|
+
if center:
|
|
396
|
+
start = max(lw + 1, (inner - cw) // 2)
|
|
397
|
+
start = min(start, inner - rw - 1 - cw) if rw else start
|
|
398
|
+
body = body + ' ' * (start - lw) + center
|
|
399
|
+
used = _fx.visible_width(body)
|
|
400
|
+
body = body + ' ' * max(0, inner - used - rw) + right
|
|
401
|
+
body = _fit_line(body, inner)
|
|
402
|
+
text = pad + body + pad
|
|
403
|
+
return self._finish([self._paint(text, self.color)], width, height)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class KeyHints(_Widget):
|
|
407
|
+
"""
|
|
408
|
+
Keyboard shortcuts as chips: ``q Salir F1 Ayuda / Buscar``.
|
|
409
|
+
|
|
410
|
+
``keys`` is a dict or ``(key, action)`` pairs. Hints wrap onto more lines
|
|
411
|
+
when they do not fit.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def __init__(self, keys, key_color='black on white', label_color=None,
|
|
415
|
+
separator=' ', width=None, align='l', use_colors=None):
|
|
416
|
+
pairs = list(keys.items()) if isinstance(keys, dict) else \
|
|
417
|
+
[tuple(k) for k in keys]
|
|
418
|
+
self.keys = [(str(k), str(v)) for k, v in pairs]
|
|
419
|
+
self.key_color = key_color
|
|
420
|
+
self.label_color = label_color
|
|
421
|
+
self.separator = separator
|
|
422
|
+
self.width = width
|
|
423
|
+
self.align = _align(align)
|
|
424
|
+
self.use_colors = use_colors
|
|
425
|
+
|
|
426
|
+
def _chips(self):
|
|
427
|
+
return [(' {0} '.format(k), ' ' + v) for k, v in self.keys]
|
|
428
|
+
|
|
429
|
+
def natural_width(self):
|
|
430
|
+
chips = self._chips()
|
|
431
|
+
return sum(_fx.visible_width(k) + _fx.visible_width(v) for k, v in chips) + \
|
|
432
|
+
_fx.visible_width(self.separator) * max(0, len(chips) - 1)
|
|
433
|
+
|
|
434
|
+
def render(self, width=None, height=None):
|
|
435
|
+
width = self._width(width)
|
|
436
|
+
lines, current, used = [], [], 0
|
|
437
|
+
sep_w = _fx.visible_width(self.separator)
|
|
438
|
+
for key, label in self._chips():
|
|
439
|
+
chip_w = _fx.visible_width(key) + _fx.visible_width(label)
|
|
440
|
+
if chip_w > width:
|
|
441
|
+
key = _fx.truncate(key, width, '')
|
|
442
|
+
label = _fx.truncate(label, max(0, width -
|
|
443
|
+
_fx.visible_width(key)), '…')
|
|
444
|
+
chip_w = _fx.visible_width(key) + _fx.visible_width(label)
|
|
445
|
+
extra = chip_w + (sep_w if current else 0)
|
|
446
|
+
if current and used + extra > width:
|
|
447
|
+
lines.append((current, used))
|
|
448
|
+
current, used = [], 0
|
|
449
|
+
extra = chip_w
|
|
450
|
+
current.append(self._paint(key, self.key_color) +
|
|
451
|
+
self._paint(label, self.label_color))
|
|
452
|
+
used += extra
|
|
453
|
+
if current:
|
|
454
|
+
lines.append((current, used))
|
|
455
|
+
out = []
|
|
456
|
+
for chips, used in lines:
|
|
457
|
+
out.append(pad_to_width(self.separator.join(chips), width,
|
|
458
|
+
self.align))
|
|
459
|
+
return self._finish(out, width, height)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_LEVELS = (
|
|
463
|
+
(re.compile(r'\b(CRITICAL|FATAL|ERROR|ERR)\b'), 'bold red'),
|
|
464
|
+
(re.compile(r'\b(WARNING|WARN)\b'), 'yellow'),
|
|
465
|
+
(re.compile(r'\b(INFO)\b'), 'cyan'),
|
|
466
|
+
(re.compile(r'\b(DEBUG|TRACE)\b'), 'bright_black'),
|
|
467
|
+
(re.compile(r'\b(OK|SUCCESS|DONE)\b'), 'green'),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class LogView(_Widget):
|
|
472
|
+
"""
|
|
473
|
+
The last lines of a log, sized to its slot.
|
|
474
|
+
|
|
475
|
+
Level words (``ERROR``, ``WARN``, ``INFO``, ``DEBUG``, ``OK``) are
|
|
476
|
+
coloured; ``level_colors`` overrides them as ``{word: spec}``. Long lines
|
|
477
|
+
are cut with ``…`` unless ``wrap=True``. ``height`` (or the slot's height
|
|
478
|
+
in a grid with a height) is how many lines to show -- the newest ones,
|
|
479
|
+
unless ``tail=False``.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def __init__(self, lines=None, height=None, width=None, wrap=False,
|
|
483
|
+
tail=True, level_colors=None, color_lines=False,
|
|
484
|
+
max_lines=1000, use_colors=None):
|
|
485
|
+
self.max_lines = max(1, int(max_lines))
|
|
486
|
+
self.lines = []
|
|
487
|
+
for line in lines or []:
|
|
488
|
+
self.append(line)
|
|
489
|
+
self.height = height
|
|
490
|
+
self.width = width
|
|
491
|
+
self.wrap = wrap
|
|
492
|
+
self.tail = tail
|
|
493
|
+
self.level_colors = level_colors
|
|
494
|
+
self.color_lines = color_lines
|
|
495
|
+
self.use_colors = use_colors
|
|
496
|
+
|
|
497
|
+
def append(self, line):
|
|
498
|
+
"""Add one line (or several, split on newlines); returns ``self``."""
|
|
499
|
+
for part in str(line).split('\n'):
|
|
500
|
+
self.lines.append(part)
|
|
501
|
+
del self.lines[:-self.max_lines]
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
def natural_width(self):
|
|
505
|
+
return max([_fx.visible_width(l) for l in self.lines] or [1])
|
|
506
|
+
|
|
507
|
+
def _spec_for(self, word):
|
|
508
|
+
word = word.upper()
|
|
509
|
+
if self.level_colors:
|
|
510
|
+
for key, spec in self.level_colors.items():
|
|
511
|
+
if key.upper() == word:
|
|
512
|
+
return spec
|
|
513
|
+
for pattern, spec in _LEVELS:
|
|
514
|
+
if pattern.fullmatch(word):
|
|
515
|
+
return spec
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
def _colour(self, line):
|
|
519
|
+
if not self._active() or '\x1b' in line:
|
|
520
|
+
return line
|
|
521
|
+
words = [p.pattern for p, _ in _LEVELS]
|
|
522
|
+
if self.level_colors:
|
|
523
|
+
words.insert(0, r'(?i:\b(' + '|'.join(
|
|
524
|
+
re.escape(k) for k in self.level_colors) + r')\b)')
|
|
525
|
+
pattern = re.compile('|'.join(words))
|
|
526
|
+
match = pattern.search(line)
|
|
527
|
+
if not match:
|
|
528
|
+
return line
|
|
529
|
+
spec = self._spec_for(match.group(0))
|
|
530
|
+
if self.color_lines:
|
|
531
|
+
return self._paint(line, spec)
|
|
532
|
+
return (line[:match.start()] + self._paint(match.group(0), spec) +
|
|
533
|
+
line[match.end():])
|
|
534
|
+
|
|
535
|
+
def render(self, width=None, height=None):
|
|
536
|
+
width = self._width(width)
|
|
537
|
+
if height is None:
|
|
538
|
+
height = self.height
|
|
539
|
+
rows = []
|
|
540
|
+
for line in self.lines:
|
|
541
|
+
if self.wrap:
|
|
542
|
+
rows.extend(wrap_to_width(line, width) or [''])
|
|
543
|
+
elif _fx.visible_width(line) > width:
|
|
544
|
+
rows.append(_fx.truncate(line, width, '…'))
|
|
545
|
+
else:
|
|
546
|
+
rows.append(line)
|
|
547
|
+
if height is not None:
|
|
548
|
+
height = int(height)
|
|
549
|
+
rows = rows[-height:] if self.tail else rows[:height]
|
|
550
|
+
if height == 0:
|
|
551
|
+
rows = []
|
|
552
|
+
out = [pad_to_width(self._colour(r), width) for r in rows] or \
|
|
553
|
+
[' ' * width]
|
|
554
|
+
return self._finish(out, width, height)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class Text(_Widget):
|
|
558
|
+
"""Text that wraps (or is cut with ``…``), aligned and coloured."""
|
|
559
|
+
|
|
560
|
+
def __init__(self, text, align='l', color=None, wrap=True, width=None,
|
|
561
|
+
use_colors=None):
|
|
562
|
+
self.text = str(text)
|
|
563
|
+
self.align = _align(align)
|
|
564
|
+
self.color = color
|
|
565
|
+
self.wrap = wrap
|
|
566
|
+
self.width = width
|
|
567
|
+
self.use_colors = use_colors
|
|
568
|
+
|
|
569
|
+
def natural_width(self):
|
|
570
|
+
return max([_fx.visible_width(l) for l in self.text.split('\n')] or [0])
|
|
571
|
+
|
|
572
|
+
def render(self, width=None, height=None):
|
|
573
|
+
width = self._width(width)
|
|
574
|
+
out = []
|
|
575
|
+
for raw in self.text.split('\n'):
|
|
576
|
+
if self.wrap:
|
|
577
|
+
pieces = wrap_to_width(raw, width) if raw else ['']
|
|
578
|
+
elif _fx.visible_width(raw) > width:
|
|
579
|
+
pieces = [_fx.truncate(raw, width, '…')]
|
|
580
|
+
else:
|
|
581
|
+
pieces = [raw]
|
|
582
|
+
for piece in pieces:
|
|
583
|
+
out.append(pad_to_width(self._paint(piece, self.color), width,
|
|
584
|
+
self.align))
|
|
585
|
+
return self._finish(out, width, height)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class Rule(_Widget):
|
|
589
|
+
"""A full-width horizontal rule, optionally titled: ``── Ventas ──``."""
|
|
590
|
+
|
|
591
|
+
def __init__(self, title=None, char='─', align='c', color='bright_black',
|
|
592
|
+
title_color='bold', width=None, use_colors=None):
|
|
593
|
+
if not char:
|
|
594
|
+
raise ValueError('char cannot be empty')
|
|
595
|
+
self.title = None if title is None else str(title)
|
|
596
|
+
self.char = char
|
|
597
|
+
self.align = _align(align, 'c')
|
|
598
|
+
self.color = color
|
|
599
|
+
self.title_color = title_color
|
|
600
|
+
self.width = width
|
|
601
|
+
self.use_colors = use_colors
|
|
602
|
+
|
|
603
|
+
def natural_width(self):
|
|
604
|
+
return (_fx.visible_width(self.title) + 6) if self.title else 1
|
|
605
|
+
|
|
606
|
+
def render(self, width=None, height=None):
|
|
607
|
+
if width is None and self.width is None:
|
|
608
|
+
width = render_width(None)
|
|
609
|
+
width = self._width(width)
|
|
610
|
+
if not self.title or _fx.visible_width(self.title) + 4 > width:
|
|
611
|
+
line = self._paint((self.char * width)[:width], self.color)
|
|
612
|
+
return self._finish([line], width, height)
|
|
613
|
+
label = ' ' + self.title + ' '
|
|
614
|
+
free = width - _fx.visible_width(label)
|
|
615
|
+
if self.align == 'l':
|
|
616
|
+
left = 2
|
|
617
|
+
elif self.align == 'r':
|
|
618
|
+
left = free - 2
|
|
619
|
+
else:
|
|
620
|
+
left = free // 2
|
|
621
|
+
line = (self._paint(self.char * left, self.color) +
|
|
622
|
+
self._paint(label, self.title_color) +
|
|
623
|
+
self._paint(self.char * (free - left), self.color))
|
|
624
|
+
return self._finish([line], width, height)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flexablelib-tui
|
|
3
|
+
Version: 3.3.0
|
|
4
|
+
Summary: Terminal interfaces for flexablelib: widgets, full-screen layout, diff redraw, keyboard input, focus and key bindings.
|
|
5
|
+
Home-page: https://github.com/Opus-Perpetuus/flexablelib
|
|
6
|
+
Author: Benjamin Ramirez
|
|
7
|
+
Author-email: chilerito12@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Environment :: Console :: Curses
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Terminals
|
|
16
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: flexablelib>=3.3.0
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: author-email
|
|
23
|
+
Dynamic: classifier
|
|
24
|
+
Dynamic: description
|
|
25
|
+
Dynamic: description-content-type
|
|
26
|
+
Dynamic: home-page
|
|
27
|
+
Dynamic: license
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
# flexablelib-tui
|
|
34
|
+
|
|
35
|
+
Terminal interfaces for [flexablelib](https://github.com/Opus-Perpetuus/flexablelib): widgets, full-screen layout, in-place redraw that writes only the lines that changed, keyboard input on Linux, macOS and Windows, focus, key bindings, scrolling, dialogs and resize.
|
|
36
|
+
|
|
37
|
+
Install it with `pip install flexablelib[tui]`.
|
|
38
|
+
|
|
39
|
+
Documentation: [LAYOUT.md](https://github.com/Opus-Perpetuus/flexablelib/blob/main/docs/LAYOUT.md) and [COMPONENTS.md](https://github.com/Opus-Perpetuus/flexablelib/blob/main/docs/COMPONENTS.md)
|
|
40
|
+
|
|
41
|
+

|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
flexablelib_tui/__init__.py,sha256=msYP3_dW0d2RDZ8YPCTPHbMYVikk0Z5IoflvUr5mIj0,1262
|
|
2
|
+
flexablelib_tui/app.py,sha256=C2TKo8c35_M2Aeh6APWtxiCIOeVNc3_phPaWGpFOQFA,23475
|
|
3
|
+
flexablelib_tui/keys.py,sha256=Wto8tL0loFH_8w9zUXFgpnxXxL_pQIRSHPECQE7mPLQ,11246
|
|
4
|
+
flexablelib_tui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
flexablelib_tui/screen.py,sha256=Vr2QHCId-4oCWcYWtCrPHBfccznrPdINptGuMFlawo0,9766
|
|
6
|
+
flexablelib_tui/terminal.py,sha256=oAWDzILwAqufJKFgW1c3OAkF9qHQknki1Qruqm9Sm_g,7818
|
|
7
|
+
flexablelib_tui/widgets.py,sha256=PJeccvPRWDSsWGjFTy5tTsVioSVbCHnht8bqWF6AMLs,23845
|
|
8
|
+
flexablelib_tui-3.3.0.dist-info/licenses/LICENSE,sha256=TsktDCE3E8N8WasPUHkR5Azor67EyerUJdk54L4wavM,1077
|
|
9
|
+
flexablelib_tui-3.3.0.dist-info/METADATA,sha256=zf1atV4sHoZwjxs30x7_VP0zBgaKMqXsxsleaZd4oJs,1714
|
|
10
|
+
flexablelib_tui-3.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
flexablelib_tui-3.3.0.dist-info/top_level.txt,sha256=gru30mHT_bpNToyvsy3Qsw6tStNIoaQxVuPxh9o1Pkg,16
|
|
12
|
+
flexablelib_tui-3.3.0.dist-info/RECORD,,
|