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,931 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data components: an interactive table with sorting, filtering, paging and
|
|
3
|
+
selection; a filter bar with a small query language; an editable table; a
|
|
4
|
+
catalog of cards; forms with validation (and a login form); file preview.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime as _dt
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from flexablelib import _frames as _fx
|
|
13
|
+
|
|
14
|
+
from .base import Button, Input, _Themed, frame, join_columns
|
|
15
|
+
from .inputs import TextInput, PasswordInput, Checkbox
|
|
16
|
+
|
|
17
|
+
__all__ = ['DataTable', 'FilterBar', 'EditableTable', 'CatalogGrid', 'Form',
|
|
18
|
+
'ValidationSummary', 'LoginForm', 'FilePreview', 'parse_query']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cell_text(value, fmt=None):
|
|
22
|
+
if fmt is not None:
|
|
23
|
+
return str(fmt(value))
|
|
24
|
+
if value is None:
|
|
25
|
+
return ''
|
|
26
|
+
if isinstance(value, bool):
|
|
27
|
+
return 'Sí' if value else 'No'
|
|
28
|
+
if isinstance(value, float):
|
|
29
|
+
return '{0:,.2f}'.format(value)
|
|
30
|
+
if isinstance(value, int):
|
|
31
|
+
return '{0:,}'.format(value)
|
|
32
|
+
if isinstance(value, (_dt.date, _dt.datetime)):
|
|
33
|
+
return value.isoformat()[:16].replace('T', ' ')
|
|
34
|
+
return str(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sort_key(value):
|
|
38
|
+
if value is None:
|
|
39
|
+
return (2, '')
|
|
40
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
41
|
+
return (0, value)
|
|
42
|
+
return (1, str(value).lower())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataTable(_Themed):
|
|
46
|
+
"""
|
|
47
|
+
An interactive table.
|
|
48
|
+
|
|
49
|
+
``columns``: names, or dicts with ``key``, ``title``, ``align``
|
|
50
|
+
(``'l'``/``'r'``/``'c'``), ``width``, ``format(value)`` and
|
|
51
|
+
``color(value) -> spec``. ``rows``: dicts or sequences.
|
|
52
|
+
|
|
53
|
+
Keys: Up/Down/PgUp/PgDn/Home/End move; ``1``-``9`` sort by that column
|
|
54
|
+
(again: reverse); ``/`` types a quick filter (Enter/Escape end it,
|
|
55
|
+
Escape twice clears it); Space selects (``selectable=True``), Ctrl-A all;
|
|
56
|
+
Left/Right scroll columns that do not fit; Enter calls
|
|
57
|
+
``on_activate(row)``. ``filters`` are extra predicates (a
|
|
58
|
+
:class:`FilterBar`'s ``predicate``). ``page_size`` pages instead of
|
|
59
|
+
scrolling.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
focusable = True
|
|
63
|
+
expand = True
|
|
64
|
+
|
|
65
|
+
def __init__(self, columns, rows=(), height=None, selectable=False,
|
|
66
|
+
on_activate=None, on_select=None, zebra=True,
|
|
67
|
+
page_size=None, status=True, empty='Sin registros',
|
|
68
|
+
**kwargs):
|
|
69
|
+
super(DataTable, self).__init__(**kwargs)
|
|
70
|
+
self.columns = [self._column(c, i) for i, c in enumerate(columns)]
|
|
71
|
+
self.rows = list(rows)
|
|
72
|
+
self.height = height
|
|
73
|
+
self.selectable = bool(selectable)
|
|
74
|
+
self.on_activate = on_activate
|
|
75
|
+
self.on_select = on_select
|
|
76
|
+
self.zebra = bool(zebra)
|
|
77
|
+
self.page_size = page_size
|
|
78
|
+
self.page = 1
|
|
79
|
+
self.status = bool(status)
|
|
80
|
+
self.empty = empty
|
|
81
|
+
self.cursor_row = 0
|
|
82
|
+
self.top = 0
|
|
83
|
+
self.col_offset = 0
|
|
84
|
+
self.sort_key = None
|
|
85
|
+
self.sort_desc = False
|
|
86
|
+
self.query = ''
|
|
87
|
+
self.typing = False
|
|
88
|
+
self.filters = []
|
|
89
|
+
self.selected = set()
|
|
90
|
+
self._view = None
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _column(column, index):
|
|
94
|
+
if isinstance(column, dict):
|
|
95
|
+
c = dict(column)
|
|
96
|
+
c.setdefault('key', index)
|
|
97
|
+
c.setdefault('title', str(c['key']))
|
|
98
|
+
return c
|
|
99
|
+
return {'key': column if isinstance(column, str) else index,
|
|
100
|
+
'title': str(column)}
|
|
101
|
+
|
|
102
|
+
def align(self, ci):
|
|
103
|
+
"""A column's alignment: given, or right for numbers."""
|
|
104
|
+
column = self.columns[ci]
|
|
105
|
+
if 'align' not in column:
|
|
106
|
+
for row in self.rows[:50]:
|
|
107
|
+
value = self._get(row, column, ci)
|
|
108
|
+
if value is not None:
|
|
109
|
+
column['_auto'] = 'r' if isinstance(value, (int, float)) \
|
|
110
|
+
and not isinstance(value, bool) else 'l'
|
|
111
|
+
break
|
|
112
|
+
return column.get('_auto', 'l')
|
|
113
|
+
return column['align']
|
|
114
|
+
|
|
115
|
+
def _get(self, row, column, index):
|
|
116
|
+
if isinstance(row, dict):
|
|
117
|
+
return row.get(column['key'])
|
|
118
|
+
k = column['key'] if isinstance(column['key'], int) else index
|
|
119
|
+
return row[k] if k < len(row) else None
|
|
120
|
+
|
|
121
|
+
def text(self, row, ci):
|
|
122
|
+
column = self.columns[ci]
|
|
123
|
+
return _cell_text(self._get(row, column, ci), column.get('format'))
|
|
124
|
+
|
|
125
|
+
def invalidate(self):
|
|
126
|
+
self._view = None
|
|
127
|
+
self.refresh()
|
|
128
|
+
|
|
129
|
+
def set_rows(self, rows):
|
|
130
|
+
self.rows = list(rows)
|
|
131
|
+
self.selected.clear()
|
|
132
|
+
self.invalidate()
|
|
133
|
+
|
|
134
|
+
def view(self):
|
|
135
|
+
"""Indices into ``rows`` after filters and sorting."""
|
|
136
|
+
if self._view is not None:
|
|
137
|
+
return self._view
|
|
138
|
+
idx = list(range(len(self.rows)))
|
|
139
|
+
q = self.query.lower()
|
|
140
|
+
if q:
|
|
141
|
+
idx = [i for i in idx if any(
|
|
142
|
+
q in self.text(self.rows[i], c).lower()
|
|
143
|
+
for c in range(len(self.columns)))]
|
|
144
|
+
for predicate in self.filters:
|
|
145
|
+
idx = [i for i in idx if predicate(self.rows[i])]
|
|
146
|
+
if self.sort_key is not None:
|
|
147
|
+
ci = self.sort_key
|
|
148
|
+
col = self.columns[ci]
|
|
149
|
+
values = {i: self._get(self.rows[i], col, ci) for i in idx}
|
|
150
|
+
# Empty cells stay at the end in both directions.
|
|
151
|
+
filled = [i for i in idx if values[i] is not None]
|
|
152
|
+
empty = [i for i in idx if values[i] is None]
|
|
153
|
+
filled.sort(key=lambda i: _sort_key(values[i]),
|
|
154
|
+
reverse=self.sort_desc)
|
|
155
|
+
idx = filled + empty
|
|
156
|
+
self._view = idx
|
|
157
|
+
return idx
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def current(self):
|
|
161
|
+
view = self.view()
|
|
162
|
+
if not view:
|
|
163
|
+
return None
|
|
164
|
+
return self.rows[view[min(self.cursor_row, len(view) - 1)]]
|
|
165
|
+
|
|
166
|
+
def selected_rows(self):
|
|
167
|
+
return [self.rows[i] for i in sorted(self.selected)]
|
|
168
|
+
|
|
169
|
+
def sort_by(self, ci, descending=None):
|
|
170
|
+
if self.sort_key == ci and descending is None:
|
|
171
|
+
self.sort_desc = not self.sort_desc
|
|
172
|
+
else:
|
|
173
|
+
self.sort_key = ci
|
|
174
|
+
self.sort_desc = bool(descending)
|
|
175
|
+
self.invalidate()
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def natural_width(self):
|
|
179
|
+
return sum(self._natural(ci) + 2 for ci in range(len(self.columns)))
|
|
180
|
+
|
|
181
|
+
def _natural(self, ci):
|
|
182
|
+
column = self.columns[ci]
|
|
183
|
+
if column.get('width'):
|
|
184
|
+
return column['width']
|
|
185
|
+
sample = self.rows[:200]
|
|
186
|
+
w = max([_fx.visible_width(self.text(r, ci)) for r in sample] + [0])
|
|
187
|
+
return max(w, _fx.visible_width(column['title']) + 2)
|
|
188
|
+
|
|
189
|
+
def _widths(self, width):
|
|
190
|
+
width -= 2 if self.selectable else 0
|
|
191
|
+
natural = [self._natural(ci) for ci in range(len(self.columns))]
|
|
192
|
+
cols = list(range(self.col_offset, len(self.columns)))
|
|
193
|
+
widths = {ci: natural[ci] for ci in cols}
|
|
194
|
+
total = lambda: sum(widths.values()) + 2 * len(widths) # noqa: E731
|
|
195
|
+
# Shrink the widest columns first, down to a minimum.
|
|
196
|
+
while total() > width:
|
|
197
|
+
wide = max(widths, key=lambda c: widths[c])
|
|
198
|
+
if widths[wide] <= 6:
|
|
199
|
+
break
|
|
200
|
+
widths[wide] -= 1
|
|
201
|
+
while total() > width and len(widths) > 1:
|
|
202
|
+
widths.pop(max(widths))
|
|
203
|
+
return widths
|
|
204
|
+
|
|
205
|
+
def _move(self, step):
|
|
206
|
+
n = len(self.view())
|
|
207
|
+
if n:
|
|
208
|
+
self.cursor_row = max(0, min(n - 1, self.cursor_row + step))
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def on_key(self, key):
|
|
212
|
+
k = str(key)
|
|
213
|
+
if self.typing:
|
|
214
|
+
if k in ('enter', 'escape'):
|
|
215
|
+
self.typing = False
|
|
216
|
+
elif k == 'backspace':
|
|
217
|
+
self.query = self.query[:-1]
|
|
218
|
+
elif key.is_printable or k == 'space':
|
|
219
|
+
self.query += key.char or ' '
|
|
220
|
+
elif k == 'paste':
|
|
221
|
+
self.query += key.char
|
|
222
|
+
else:
|
|
223
|
+
return k not in ('tab', 'shift+tab')
|
|
224
|
+
self.cursor_row = 0
|
|
225
|
+
self.invalidate()
|
|
226
|
+
return True
|
|
227
|
+
view = self.view()
|
|
228
|
+
page = self.page_size or self._rows_shown
|
|
229
|
+
if k in ('up', 'k'):
|
|
230
|
+
self._move(-1)
|
|
231
|
+
elif k in ('down', 'j'):
|
|
232
|
+
self._move(1)
|
|
233
|
+
elif k == 'pageup':
|
|
234
|
+
self._move(-page)
|
|
235
|
+
elif k == 'pagedown':
|
|
236
|
+
self._move(page)
|
|
237
|
+
elif k in ('home', 'g'):
|
|
238
|
+
self.cursor_row = 0
|
|
239
|
+
elif k in ('end', 'G'):
|
|
240
|
+
self.cursor_row = max(0, len(view) - 1)
|
|
241
|
+
elif k == 'right':
|
|
242
|
+
self.col_offset = min(len(self.columns) - 1, self.col_offset + 1)
|
|
243
|
+
elif k == 'left':
|
|
244
|
+
self.col_offset = max(0, self.col_offset - 1)
|
|
245
|
+
elif len(k) == 1 and k.isdigit() and 0 < int(k) <= len(self.columns):
|
|
246
|
+
return self.sort_by(int(k) - 1)
|
|
247
|
+
elif k == '/':
|
|
248
|
+
self.typing = True
|
|
249
|
+
elif k == 'escape' and self.query:
|
|
250
|
+
self.query = ''
|
|
251
|
+
self.invalidate()
|
|
252
|
+
elif k == 'space' and self.selectable and view:
|
|
253
|
+
i = view[min(self.cursor_row, len(view) - 1)]
|
|
254
|
+
self.selected.symmetric_difference_update([i])
|
|
255
|
+
if self.on_select is not None:
|
|
256
|
+
self.on_select(self.selected_rows())
|
|
257
|
+
self._move(1)
|
|
258
|
+
elif k == 'ctrl+a' and self.selectable:
|
|
259
|
+
if len(self.selected) == len(view):
|
|
260
|
+
self.selected.clear()
|
|
261
|
+
else:
|
|
262
|
+
self.selected = set(view)
|
|
263
|
+
if self.on_select is not None:
|
|
264
|
+
self.on_select(self.selected_rows())
|
|
265
|
+
elif k == 'enter' and view and self.on_activate is not None:
|
|
266
|
+
self.on_activate(self.current)
|
|
267
|
+
else:
|
|
268
|
+
return False
|
|
269
|
+
self.refresh()
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
_rows_shown = 10
|
|
273
|
+
|
|
274
|
+
def draw(self, width, height):
|
|
275
|
+
view = self.view()
|
|
276
|
+
widths = self._widths(width)
|
|
277
|
+
order = sorted(widths)
|
|
278
|
+
mark_w = 2 if self.selectable else 0
|
|
279
|
+
head = ' ' * mark_w
|
|
280
|
+
for ci in order:
|
|
281
|
+
col = self.columns[ci]
|
|
282
|
+
title = col['title']
|
|
283
|
+
if ci == self.sort_key:
|
|
284
|
+
title += ' ↓' if self.sort_desc else ' ↑'
|
|
285
|
+
head += ' ' + _fx.fit_line(_fx.truncate(title, widths[ci], '…'),
|
|
286
|
+
widths[ci], self.align(ci)) + ' '
|
|
287
|
+
out = [self.paint(_fx.fit_line(head, width), 'bold')]
|
|
288
|
+
out.append(self.paint('─' * width, self.theme.accent if self.focused
|
|
289
|
+
else self.theme.border))
|
|
290
|
+
extra = (1 if self.status else 0) + (1 if self.typing or self.query
|
|
291
|
+
else 0)
|
|
292
|
+
rows_h = self.height or (height - 2 - extra if height else None)
|
|
293
|
+
if self.page_size:
|
|
294
|
+
rows_h = self.page_size
|
|
295
|
+
if rows_h is None:
|
|
296
|
+
rows_h = len(view)
|
|
297
|
+
rows_h = max(1, rows_h)
|
|
298
|
+
self._rows_shown = rows_h
|
|
299
|
+
self.cursor_row = max(0, min(self.cursor_row, len(view) - 1))
|
|
300
|
+
if self.page_size:
|
|
301
|
+
self.page = self.cursor_row // self.page_size + 1
|
|
302
|
+
self.top = (self.page - 1) * self.page_size
|
|
303
|
+
else:
|
|
304
|
+
if self.cursor_row < self.top:
|
|
305
|
+
self.top = self.cursor_row
|
|
306
|
+
if self.cursor_row >= self.top + rows_h:
|
|
307
|
+
self.top = self.cursor_row - rows_h + 1
|
|
308
|
+
shown = view[self.top:self.top + rows_h]
|
|
309
|
+
if not view:
|
|
310
|
+
out.append(self.paint(_fx.fit_line(self.empty, width, 'c'),
|
|
311
|
+
self.theme.muted))
|
|
312
|
+
for n, i in enumerate(shown):
|
|
313
|
+
row = self.rows[i]
|
|
314
|
+
line = ''
|
|
315
|
+
if mark_w:
|
|
316
|
+
on = i in self.selected
|
|
317
|
+
line += (('☑ ' if on else '☐ ') if self._active() else
|
|
318
|
+
('x ' if on else ' '))
|
|
319
|
+
for ci in order:
|
|
320
|
+
col = self.columns[ci]
|
|
321
|
+
text = _fx.truncate(self.text(row, ci), widths[ci], '…')
|
|
322
|
+
text = _fx.fit_line(text, widths[ci], self.align(ci))
|
|
323
|
+
color = col.get('color')
|
|
324
|
+
if color is not None and self._active():
|
|
325
|
+
text = self.paint(text, color(self._get(row, col, ci)))
|
|
326
|
+
line += ' ' + text + ' '
|
|
327
|
+
line = _fx.fit_line(line, width)
|
|
328
|
+
here = self.top + n == self.cursor_row
|
|
329
|
+
if here and self.focused:
|
|
330
|
+
line = self.paint(_fx.strip_ansi(line), self.theme.selection) \
|
|
331
|
+
if self._active() else '>' + line[1:]
|
|
332
|
+
elif here:
|
|
333
|
+
line = self.paint(line, 'bold')
|
|
334
|
+
elif self.zebra and n % 2 and self._active():
|
|
335
|
+
line = self.paint(line, 'on #1c1c1c')
|
|
336
|
+
out.append(line)
|
|
337
|
+
if self.typing or self.query:
|
|
338
|
+
cur = self.cursor(' ') if self.typing else ''
|
|
339
|
+
out.append(self.paint('/', self.theme.accent) + self.query + cur)
|
|
340
|
+
if self.status:
|
|
341
|
+
pos = '{0}/{1}'.format(self.cursor_row + 1 if view else 0,
|
|
342
|
+
len(view))
|
|
343
|
+
parts = [pos]
|
|
344
|
+
if len(view) != len(self.rows):
|
|
345
|
+
parts.append('de {0}'.format(len(self.rows)))
|
|
346
|
+
if self.page_size:
|
|
347
|
+
pages = max(1, -(-len(view) // self.page_size))
|
|
348
|
+
parts.append('pág. {0}/{1}'.format(self.page, pages))
|
|
349
|
+
if self.selected:
|
|
350
|
+
parts.append('{0} seleccionadas'.format(len(self.selected)))
|
|
351
|
+
hidden = len(self.columns) - len(order)
|
|
352
|
+
if hidden or self.col_offset:
|
|
353
|
+
parts.append('◀▶ {0} columnas más'.format(
|
|
354
|
+
len(self.columns) - len(order)))
|
|
355
|
+
out.append(self.paint(' · '.join(parts), self.theme.muted))
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
_QUERY = re.compile(r'(\S+?)(>=|<=|!=|:|=|>|<)("[^"]*"|\S+)|("[^"]*"|\S+)')
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def parse_query(query, fields=None):
|
|
363
|
+
"""
|
|
364
|
+
``[(field, op, value), ...]`` from ``estado:pagado total>1000 mérida``;
|
|
365
|
+
a bare word is ``(None, ':', word)`` (matches any field). ``fields``
|
|
366
|
+
limits the field names recognised.
|
|
367
|
+
"""
|
|
368
|
+
out = []
|
|
369
|
+
for match in _QUERY.finditer(query):
|
|
370
|
+
if match.group(1) and (fields is None or match.group(1) in fields):
|
|
371
|
+
value = match.group(3).strip('"')
|
|
372
|
+
out.append((match.group(1), match.group(2), value))
|
|
373
|
+
else:
|
|
374
|
+
word = (match.group(4) or match.group(0)).strip('"')
|
|
375
|
+
out.append((None, ':', word))
|
|
376
|
+
return out
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _compare(cell, op, value):
|
|
380
|
+
if op in ('>', '<', '>=', '<='):
|
|
381
|
+
if cell is None:
|
|
382
|
+
return False
|
|
383
|
+
try:
|
|
384
|
+
a, b = float(cell), float(value)
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
a, b = str(cell or ''), value
|
|
387
|
+
return {'>': a > b, '<': a < b, '>=': a >= b, '<=': a <= b}[op]
|
|
388
|
+
text = '' if cell is None else str(cell).lower()
|
|
389
|
+
if op == ':':
|
|
390
|
+
return value.lower() in text
|
|
391
|
+
if op == '=':
|
|
392
|
+
return text == value.lower()
|
|
393
|
+
return text != value.lower()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class FilterBar(TextInput):
|
|
397
|
+
"""
|
|
398
|
+
Filters typed as a query -- ``estado:pagado total>1000 mérida`` (``:``
|
|
399
|
+
contains, ``=`` / ``!=`` equal, ``>`` ``<`` ``>=`` ``<=`` compare, a
|
|
400
|
+
bare word searches every field) -- shown as chips below. ``predicate``
|
|
401
|
+
is the row filter; ``table`` (a :class:`DataTable`) is refreshed on
|
|
402
|
+
every change.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(self, fields=None, table=None, label=None, **kwargs):
|
|
406
|
+
self.fields = list(fields) if fields else None
|
|
407
|
+
self.table = table
|
|
408
|
+
kwargs.setdefault('placeholder', 'Filtrar: campo:valor total>100 '
|
|
409
|
+
'texto')
|
|
410
|
+
super(FilterBar, self).__init__(label=label, **kwargs)
|
|
411
|
+
user_change = self.on_change
|
|
412
|
+
|
|
413
|
+
def changed(value):
|
|
414
|
+
if self.table is not None:
|
|
415
|
+
self.table.cursor_row = 0
|
|
416
|
+
self.table.invalidate()
|
|
417
|
+
if user_change is not None:
|
|
418
|
+
user_change(value)
|
|
419
|
+
self.on_change = changed
|
|
420
|
+
if table is not None:
|
|
421
|
+
table.filters.append(self.predicate)
|
|
422
|
+
|
|
423
|
+
def terms(self):
|
|
424
|
+
return parse_query(self.text, self.fields)
|
|
425
|
+
|
|
426
|
+
def predicate(self, row):
|
|
427
|
+
for field, op, value in self.terms():
|
|
428
|
+
if field is None:
|
|
429
|
+
cells = row.values() if isinstance(row, dict) else row
|
|
430
|
+
if not any(value.lower() in str(c).lower() for c in cells
|
|
431
|
+
if c is not None):
|
|
432
|
+
return False
|
|
433
|
+
else:
|
|
434
|
+
cell = row.get(field) if isinstance(row, dict) else None
|
|
435
|
+
if not _compare(cell, op, value):
|
|
436
|
+
return False
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def control(self, width, height):
|
|
440
|
+
out = super(FilterBar, self).control(width, height)
|
|
441
|
+
chips = []
|
|
442
|
+
for field, op, value in self.terms():
|
|
443
|
+
text = value if field is None else '{0}{1}{2}'.format(field, op,
|
|
444
|
+
value)
|
|
445
|
+
chips.append(self.paint(' ' + text + ' ', 'black on ' +
|
|
446
|
+
self.theme.accent) if self._active()
|
|
447
|
+
else '[' + text + ']')
|
|
448
|
+
if chips:
|
|
449
|
+
out.append(' ' + ' '.join(chips))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class EditableTable(DataTable):
|
|
454
|
+
"""
|
|
455
|
+
A :class:`DataTable` whose cells are edited in place: Left/Right choose
|
|
456
|
+
the cell, Enter edits it (Enter saves, Escape cancels), Ctrl-N adds a
|
|
457
|
+
row, Ctrl-D deletes the current one. Column ``type`` ``'number'`` keeps
|
|
458
|
+
numbers. ``on_edit(row, key, value)`` runs after a change.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
def __init__(self, columns, rows=(), on_edit=None, **kwargs):
|
|
462
|
+
super(EditableTable, self).__init__(columns, rows, **kwargs)
|
|
463
|
+
self.on_edit = on_edit
|
|
464
|
+
self.cursor_col = 0
|
|
465
|
+
self.editor = None
|
|
466
|
+
|
|
467
|
+
def children(self):
|
|
468
|
+
return []
|
|
469
|
+
|
|
470
|
+
def _set(self, row, ci, value):
|
|
471
|
+
column = self.columns[ci]
|
|
472
|
+
if column.get('type') == 'number':
|
|
473
|
+
try:
|
|
474
|
+
value = float(value) if '.' in str(value) else int(value)
|
|
475
|
+
except ValueError:
|
|
476
|
+
return False
|
|
477
|
+
if isinstance(row, dict):
|
|
478
|
+
row[column['key']] = value
|
|
479
|
+
else:
|
|
480
|
+
k = column['key'] if isinstance(column['key'], int) else ci
|
|
481
|
+
while len(row) <= k:
|
|
482
|
+
row.append(None)
|
|
483
|
+
row[k] = value
|
|
484
|
+
if self.on_edit is not None:
|
|
485
|
+
self.on_edit(row, column['key'], value)
|
|
486
|
+
return True
|
|
487
|
+
|
|
488
|
+
def on_key(self, key):
|
|
489
|
+
k = str(key)
|
|
490
|
+
if self.editor is not None:
|
|
491
|
+
if k == 'escape':
|
|
492
|
+
self.editor = None
|
|
493
|
+
elif k == 'enter':
|
|
494
|
+
if self._set(self.current, self.cursor_col, self.editor.text):
|
|
495
|
+
self.editor = None
|
|
496
|
+
self.invalidate()
|
|
497
|
+
else:
|
|
498
|
+
self.editor.error = 'Número inválido'
|
|
499
|
+
else:
|
|
500
|
+
self.editor.on_key(key)
|
|
501
|
+
self.refresh()
|
|
502
|
+
return True
|
|
503
|
+
if k in ('left', 'right'):
|
|
504
|
+
step = 1 if k == 'right' else -1
|
|
505
|
+
self.cursor_col = max(0, min(len(self.columns) - 1,
|
|
506
|
+
self.cursor_col + step))
|
|
507
|
+
self.refresh()
|
|
508
|
+
return True
|
|
509
|
+
if k == 'enter' and self.current is not None:
|
|
510
|
+
value = self._get(self.current, self.columns[self.cursor_col],
|
|
511
|
+
self.cursor_col)
|
|
512
|
+
self.editor = TextInput(value='' if value is None else str(value),
|
|
513
|
+
theme=self.theme,
|
|
514
|
+
use_colors=self.use_colors)
|
|
515
|
+
self.editor.focused = True
|
|
516
|
+
self.refresh()
|
|
517
|
+
return True
|
|
518
|
+
if k == 'ctrl+n':
|
|
519
|
+
new = {c['key']: None for c in self.columns} if (
|
|
520
|
+
not self.rows or isinstance(self.rows[0], dict)) else \
|
|
521
|
+
[None] * len(self.columns)
|
|
522
|
+
self.rows.append(new)
|
|
523
|
+
self.invalidate()
|
|
524
|
+
self.cursor_row = len(self.view()) - 1
|
|
525
|
+
return True
|
|
526
|
+
if k == 'ctrl+d' and self.current is not None:
|
|
527
|
+
self.rows.remove(self.current)
|
|
528
|
+
self.invalidate()
|
|
529
|
+
return True
|
|
530
|
+
return super(EditableTable, self).on_key(key)
|
|
531
|
+
|
|
532
|
+
def draw(self, width, height):
|
|
533
|
+
lines = super(EditableTable, self).draw(width, height)
|
|
534
|
+
view = self.view()
|
|
535
|
+
if not view or not self.focused:
|
|
536
|
+
return lines
|
|
537
|
+
widths = self._widths(width)
|
|
538
|
+
order = sorted(widths)
|
|
539
|
+
if self.cursor_col not in widths:
|
|
540
|
+
return lines
|
|
541
|
+
x = sum(widths[c] + 2 for c in order if c < self.cursor_col)
|
|
542
|
+
w = widths[self.cursor_col]
|
|
543
|
+
row = 2 + self.cursor_row - self.top
|
|
544
|
+
if not 2 <= row < len(lines):
|
|
545
|
+
return lines
|
|
546
|
+
plain = _fx.strip_ansi(lines[row])
|
|
547
|
+
if self.editor is not None:
|
|
548
|
+
cell = _fx.fit_line(self.editor._viewport(self.editor.text, w), w)
|
|
549
|
+
cell = self.paint(cell, 'underline') if self._active() else cell
|
|
550
|
+
else:
|
|
551
|
+
cell = _fx.slice_columns(plain, x + 1, w)
|
|
552
|
+
cell = self.paint(cell, 'reverse bold') if self._active() else \
|
|
553
|
+
cell
|
|
554
|
+
left = self.paint(_fx.slice_columns(plain, 0, x + 1),
|
|
555
|
+
self.theme.selection) if self._active() else \
|
|
556
|
+
_fx.slice_columns(plain, 0, x + 1)
|
|
557
|
+
right_w = width - x - 1 - w
|
|
558
|
+
right = self.paint(_fx.slice_columns(plain, x + 1 + w, right_w),
|
|
559
|
+
self.theme.selection) if self._active() and \
|
|
560
|
+
right_w > 0 else _fx.slice_columns(plain, x + 1 + w, right_w)
|
|
561
|
+
lines[row] = left + cell + right
|
|
562
|
+
return lines
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class CatalogGrid(_Themed):
|
|
566
|
+
"""
|
|
567
|
+
Items as cards in a grid that reflows with the width: dicts with
|
|
568
|
+
``title``, ``subtitle``, ``price``, ``badge`` and ``fields``. Arrows
|
|
569
|
+
move, Enter calls ``on_select(item)``.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
focusable = True
|
|
573
|
+
expand = True
|
|
574
|
+
|
|
575
|
+
def __init__(self, items, min_width=24, on_select=None, card_height=6,
|
|
576
|
+
**kwargs):
|
|
577
|
+
super(CatalogGrid, self).__init__(**kwargs)
|
|
578
|
+
self.items = list(items)
|
|
579
|
+
self.min_width = min_width
|
|
580
|
+
self.on_select = on_select
|
|
581
|
+
self.card_height = card_height
|
|
582
|
+
self.index = 0
|
|
583
|
+
self.top_row = 0
|
|
584
|
+
self._columns = 1
|
|
585
|
+
|
|
586
|
+
def natural_width(self):
|
|
587
|
+
return self.min_width * 3 + 2
|
|
588
|
+
|
|
589
|
+
def on_key(self, key):
|
|
590
|
+
k = str(key)
|
|
591
|
+
n = len(self.items)
|
|
592
|
+
moves = {'left': -1, 'right': 1, 'up': -self._columns,
|
|
593
|
+
'down': self._columns}
|
|
594
|
+
if k in moves and n:
|
|
595
|
+
self.index = max(0, min(n - 1, self.index + moves[k]))
|
|
596
|
+
elif k == 'enter' and n and self.on_select is not None:
|
|
597
|
+
self.on_select(self.items[self.index])
|
|
598
|
+
elif k == 'home':
|
|
599
|
+
self.index = 0
|
|
600
|
+
elif k == 'end':
|
|
601
|
+
self.index = max(0, n - 1)
|
|
602
|
+
else:
|
|
603
|
+
return False
|
|
604
|
+
self.refresh()
|
|
605
|
+
return True
|
|
606
|
+
|
|
607
|
+
def _card(self, item, width, selected):
|
|
608
|
+
lines = []
|
|
609
|
+
title = str(item.get('title', ''))
|
|
610
|
+
badge = item.get('badge')
|
|
611
|
+
inner = max(1, width - 4)
|
|
612
|
+
head = self.paint(_fx.truncate(title, inner - (len(str(badge)) + 3
|
|
613
|
+
if badge else 0), '…'),
|
|
614
|
+
'bold')
|
|
615
|
+
if badge:
|
|
616
|
+
head = _fx.fit_line(head, inner - len(str(badge)) - 2) + \
|
|
617
|
+
self.paint(' ' + str(badge) + ' ', 'black on ' +
|
|
618
|
+
self.theme.warning) if self._active() else \
|
|
619
|
+
head + ' [' + str(badge) + ']'
|
|
620
|
+
lines.append(head)
|
|
621
|
+
if item.get('subtitle'):
|
|
622
|
+
lines.append(self.paint(_fx.truncate(str(item['subtitle']), inner,
|
|
623
|
+
'…'), self.theme.muted))
|
|
624
|
+
for label, value in (item.get('fields') or {}).items():
|
|
625
|
+
lines.append(_fx.truncate('{0}: {1}'.format(label, value), inner,
|
|
626
|
+
'…'))
|
|
627
|
+
if item.get('price') is not None:
|
|
628
|
+
price = item['price']
|
|
629
|
+
text = '${0:,.2f}'.format(price) if isinstance(price, (int, float)) \
|
|
630
|
+
else str(price)
|
|
631
|
+
lines.append(self.paint(text, 'bold ' + self.theme.success))
|
|
632
|
+
rows = self.card_height - 2
|
|
633
|
+
lines = lines[:rows] + [''] * (rows - len(lines))
|
|
634
|
+
color = self.theme.accent if selected else self.theme.border
|
|
635
|
+
return frame([' ' + l for l in lines], width, None, color,
|
|
636
|
+
self.paint, style='heavy' if selected and
|
|
637
|
+
not self._active() else 'rounded')
|
|
638
|
+
|
|
639
|
+
def draw(self, width, height):
|
|
640
|
+
cols = max(1, (width + 1) // (self.min_width + 1))
|
|
641
|
+
self._columns = cols
|
|
642
|
+
card_w = (width - (cols - 1)) // cols
|
|
643
|
+
n_rows = int(math.ceil(len(self.items) / float(cols)))
|
|
644
|
+
visible_rows = n_rows if not height else max(1, height //
|
|
645
|
+
self.card_height)
|
|
646
|
+
row_of = self.index // cols
|
|
647
|
+
if row_of < self.top_row:
|
|
648
|
+
self.top_row = row_of
|
|
649
|
+
if row_of >= self.top_row + visible_rows:
|
|
650
|
+
self.top_row = row_of - visible_rows + 1
|
|
651
|
+
out = []
|
|
652
|
+
for r in range(self.top_row, min(n_rows, self.top_row +
|
|
653
|
+
visible_rows)):
|
|
654
|
+
blocks = []
|
|
655
|
+
for c in range(cols):
|
|
656
|
+
i = r * cols + c
|
|
657
|
+
if i >= len(self.items):
|
|
658
|
+
break
|
|
659
|
+
blocks.append(self._card(self.items[i], card_w,
|
|
660
|
+
self.focused and i == self.index))
|
|
661
|
+
out.extend(join_columns(blocks, [card_w] * len(blocks)))
|
|
662
|
+
if not self.items:
|
|
663
|
+
out.append(self.paint('Sin elementos', self.theme.muted))
|
|
664
|
+
return out
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class ValidationSummary(_Themed):
|
|
668
|
+
"""The list of errors of a form: ``{field: message}``."""
|
|
669
|
+
|
|
670
|
+
def __init__(self, errors=None, title='Revisa estos campos', **kwargs):
|
|
671
|
+
super(ValidationSummary, self).__init__(**kwargs)
|
|
672
|
+
self.errors = dict(errors or {})
|
|
673
|
+
self.title = title
|
|
674
|
+
|
|
675
|
+
def draw(self, width, height):
|
|
676
|
+
if not self.errors:
|
|
677
|
+
return []
|
|
678
|
+
lines = [self.paint('✖ ' + self.title, 'bold ' + self.theme.error)]
|
|
679
|
+
for field, message in self.errors.items():
|
|
680
|
+
lines.append(' • {0}: {1}'.format(field, message))
|
|
681
|
+
return frame([' ' + l for l in lines], width, None, self.theme.error,
|
|
682
|
+
self.paint)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class Form(_Themed):
|
|
686
|
+
"""
|
|
687
|
+
Fields with labels, help and validation, laid out in ``columns``, with
|
|
688
|
+
submit / cancel buttons. ``fields``: inputs, section titles (strings) or
|
|
689
|
+
tuples of inputs for one row. Enter in a one-line field moves to the
|
|
690
|
+
next; Ctrl-S (or the submit button) validates and calls
|
|
691
|
+
``on_submit(values)``; Escape calls ``on_cancel()``. ``values()`` is the
|
|
692
|
+
``{name: value}`` dict; ``errors`` the last validation.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
def __init__(self, fields, on_submit=None, on_cancel=None,
|
|
696
|
+
submit_label='Guardar', cancel_label='Cancelar', columns=1,
|
|
697
|
+
title=None, summary=True, **kwargs):
|
|
698
|
+
super(Form, self).__init__(**kwargs)
|
|
699
|
+
self.rows = []
|
|
700
|
+
for item in fields:
|
|
701
|
+
if isinstance(item, (tuple, list)):
|
|
702
|
+
self.rows.append(list(item))
|
|
703
|
+
else:
|
|
704
|
+
self.rows.append([item])
|
|
705
|
+
self.on_submit = on_submit
|
|
706
|
+
self.on_cancel = on_cancel
|
|
707
|
+
self.columns = max(1, int(columns))
|
|
708
|
+
self.title = title
|
|
709
|
+
self.summary = bool(summary)
|
|
710
|
+
self.errors = {}
|
|
711
|
+
self.submitted = None
|
|
712
|
+
colors = kwargs.get('use_colors')
|
|
713
|
+
self.submit_button = Button(submit_label, self.submit, kind='primary',
|
|
714
|
+
shortcut=None, theme=self.theme,
|
|
715
|
+
use_colors=colors)
|
|
716
|
+
self.cancel_button = Button(cancel_label, self.cancel,
|
|
717
|
+
theme=self.theme, use_colors=colors) \
|
|
718
|
+
if on_cancel is not None or cancel_label else None
|
|
719
|
+
for field in self.fields():
|
|
720
|
+
if isinstance(field, TextInput) and field.on_submit is None:
|
|
721
|
+
field.on_submit = self._next_from(field)
|
|
722
|
+
|
|
723
|
+
def _next_from(self, field):
|
|
724
|
+
def advance(value):
|
|
725
|
+
if self.app is not None:
|
|
726
|
+
self.app.focus_next()
|
|
727
|
+
return advance
|
|
728
|
+
|
|
729
|
+
def fields(self):
|
|
730
|
+
return [f for row in self.rows for f in row if isinstance(f, Input)]
|
|
731
|
+
|
|
732
|
+
def children(self):
|
|
733
|
+
kids = [f for row in self.rows for f in row if not isinstance(f, str)]
|
|
734
|
+
# Same order as drawn: Tab walks the buttons left to right.
|
|
735
|
+
if self.cancel_button is not None:
|
|
736
|
+
kids.append(self.cancel_button)
|
|
737
|
+
kids.append(self.submit_button)
|
|
738
|
+
return kids
|
|
739
|
+
|
|
740
|
+
def natural_width(self):
|
|
741
|
+
return 60 if self.columns == 1 else 90
|
|
742
|
+
|
|
743
|
+
def values(self):
|
|
744
|
+
return {f.name: f.value for f in self.fields() if f.name}
|
|
745
|
+
|
|
746
|
+
def validate(self):
|
|
747
|
+
self.errors = {}
|
|
748
|
+
for f in self.fields():
|
|
749
|
+
error = f.validate()
|
|
750
|
+
if error:
|
|
751
|
+
self.errors[f.label or f.name] = error
|
|
752
|
+
return self.errors
|
|
753
|
+
|
|
754
|
+
def submit(self):
|
|
755
|
+
if self.validate():
|
|
756
|
+
bad = next((f for f in self.fields() if f.error), None)
|
|
757
|
+
if bad is not None and self.app is not None:
|
|
758
|
+
self.app.focus(bad)
|
|
759
|
+
self.refresh()
|
|
760
|
+
return True
|
|
761
|
+
self.submitted = self.values()
|
|
762
|
+
if self.on_submit is not None:
|
|
763
|
+
self.on_submit(self.submitted)
|
|
764
|
+
self.refresh()
|
|
765
|
+
return True
|
|
766
|
+
|
|
767
|
+
def cancel(self):
|
|
768
|
+
if self.on_cancel is not None:
|
|
769
|
+
self.on_cancel()
|
|
770
|
+
return True
|
|
771
|
+
|
|
772
|
+
def handle_key(self, key):
|
|
773
|
+
k = str(key)
|
|
774
|
+
if k == 'ctrl+s':
|
|
775
|
+
return self.submit()
|
|
776
|
+
if k == 'escape' and self.on_cancel is not None:
|
|
777
|
+
return self.cancel()
|
|
778
|
+
return super(Form, self).handle_key(key)
|
|
779
|
+
|
|
780
|
+
def draw(self, width, height):
|
|
781
|
+
out = []
|
|
782
|
+
if self.title:
|
|
783
|
+
out.append(self.paint(self.title, 'bold ' + self.theme.accent))
|
|
784
|
+
out.append('')
|
|
785
|
+
if self.summary and self.errors:
|
|
786
|
+
out.extend(ValidationSummary(self.errors, theme=self.theme,
|
|
787
|
+
use_colors=self.use_colors)
|
|
788
|
+
.render(width=width).split('\n'))
|
|
789
|
+
pending = []
|
|
790
|
+
|
|
791
|
+
def flush():
|
|
792
|
+
if not pending:
|
|
793
|
+
return
|
|
794
|
+
n = len(pending)
|
|
795
|
+
each = (width - 2 * (n - 1)) // n
|
|
796
|
+
blocks = [p.render(width=each).split('\n') for p in pending]
|
|
797
|
+
out.extend(join_columns(blocks, [each] * n, gap=2))
|
|
798
|
+
out.append('')
|
|
799
|
+
del pending[:]
|
|
800
|
+
for row in self.rows:
|
|
801
|
+
if len(row) == 1 and isinstance(row[0], str):
|
|
802
|
+
flush()
|
|
803
|
+
out.append(self.paint(row[0], 'bold'))
|
|
804
|
+
out.append(self.paint('─' * width, self.theme.border))
|
|
805
|
+
continue
|
|
806
|
+
if len(row) > 1:
|
|
807
|
+
flush()
|
|
808
|
+
pending.extend(row)
|
|
809
|
+
flush()
|
|
810
|
+
continue
|
|
811
|
+
pending.append(row[0])
|
|
812
|
+
if len(pending) >= self.columns:
|
|
813
|
+
flush()
|
|
814
|
+
flush()
|
|
815
|
+
buttons = self.submit_button.render(
|
|
816
|
+
width=self.submit_button.natural_width())
|
|
817
|
+
if self.cancel_button is not None:
|
|
818
|
+
buttons = self.cancel_button.render(
|
|
819
|
+
width=self.cancel_button.natural_width()) + ' ' + buttons
|
|
820
|
+
out.append(_fx.fit_line(buttons, width, 'r'))
|
|
821
|
+
return out
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class LoginForm(Form):
|
|
825
|
+
"""
|
|
826
|
+
User and password, a "remember me" box and an Enter button.
|
|
827
|
+
``on_login(user, password, remember)`` returns True, or an error message
|
|
828
|
+
shown above the fields.
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def __init__(self, on_login=None, title='Iniciar sesión',
|
|
832
|
+
user_label='Usuario', **kwargs):
|
|
833
|
+
colors = kwargs.get('use_colors')
|
|
834
|
+
theme = kwargs.get('theme')
|
|
835
|
+
self.user = TextInput(user_label, name='user', required=True,
|
|
836
|
+
theme=theme, use_colors=colors)
|
|
837
|
+
self.password = PasswordInput('Contraseña', name='password',
|
|
838
|
+
required=True, theme=theme,
|
|
839
|
+
use_colors=colors)
|
|
840
|
+
self.remember = Checkbox('Recordarme', name='remember', theme=theme,
|
|
841
|
+
use_colors=colors)
|
|
842
|
+
self.on_login = on_login
|
|
843
|
+
self.message = None
|
|
844
|
+
self.logged_in = False
|
|
845
|
+
super(LoginForm, self).__init__(
|
|
846
|
+
[self.user, self.password, self.remember],
|
|
847
|
+
on_submit=self._login, submit_label='Entrar', cancel_label=None,
|
|
848
|
+
title=title, summary=False, **kwargs)
|
|
849
|
+
self.password.on_submit = lambda value: self.submit()
|
|
850
|
+
|
|
851
|
+
def _login(self, values):
|
|
852
|
+
result = True
|
|
853
|
+
if self.on_login is not None:
|
|
854
|
+
result = self.on_login(values['user'], values['password'],
|
|
855
|
+
values['remember'])
|
|
856
|
+
if result is True:
|
|
857
|
+
self.logged_in = True
|
|
858
|
+
self.message = None
|
|
859
|
+
else:
|
|
860
|
+
self.message = str(result or 'Usuario o contraseña incorrectos')
|
|
861
|
+
self.password.value = ''
|
|
862
|
+
|
|
863
|
+
def natural_width(self):
|
|
864
|
+
return 44
|
|
865
|
+
|
|
866
|
+
def draw(self, width, height):
|
|
867
|
+
out = super(LoginForm, self).draw(width, height)
|
|
868
|
+
if self.message:
|
|
869
|
+
out.insert(2 if self.title else 0,
|
|
870
|
+
self.paint('✖ ' + self.message, self.theme.error))
|
|
871
|
+
return out
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
class FilePreview(_Themed):
|
|
875
|
+
"""
|
|
876
|
+
A file's name, size, date and -- for text -- its first lines, with code
|
|
877
|
+
highlighting by extension. Images, PDFs and other binaries show their
|
|
878
|
+
details only (a terminal cannot draw them portably).
|
|
879
|
+
"""
|
|
880
|
+
|
|
881
|
+
LANGUAGES = {'.py': 'python', '.js': 'javascript', '.ts': 'typescript',
|
|
882
|
+
'.sql': 'sql', '.sh': 'bash', '.json': 'json'}
|
|
883
|
+
|
|
884
|
+
def __init__(self, path, max_lines=20, **kwargs):
|
|
885
|
+
super(FilePreview, self).__init__(**kwargs)
|
|
886
|
+
self.path = path
|
|
887
|
+
self.max_lines = max_lines
|
|
888
|
+
|
|
889
|
+
def natural_width(self):
|
|
890
|
+
return 70
|
|
891
|
+
|
|
892
|
+
def _content(self):
|
|
893
|
+
try:
|
|
894
|
+
with open(self.path, 'rb') as handle:
|
|
895
|
+
head = handle.read(64 * 1024)
|
|
896
|
+
except OSError as error:
|
|
897
|
+
return None, str(error)
|
|
898
|
+
if b'\x00' in head:
|
|
899
|
+
return None, 'Vista previa no disponible (archivo binario)'
|
|
900
|
+
try:
|
|
901
|
+
return head.decode('utf-8'), None
|
|
902
|
+
except UnicodeDecodeError:
|
|
903
|
+
return head.decode('latin-1'), None
|
|
904
|
+
|
|
905
|
+
def draw(self, width, height):
|
|
906
|
+
from .display import Code, human_size
|
|
907
|
+
name = os.path.basename(self.path)
|
|
908
|
+
try:
|
|
909
|
+
stat = os.stat(self.path)
|
|
910
|
+
meta = '{0} · {1:%Y-%m-%d %H:%M}'.format(
|
|
911
|
+
human_size(stat.st_size),
|
|
912
|
+
_dt.datetime.fromtimestamp(stat.st_mtime))
|
|
913
|
+
except OSError:
|
|
914
|
+
meta = 'no encontrado'
|
|
915
|
+
out = [self.paint(name, 'bold') + ' ' + self.paint(meta,
|
|
916
|
+
self.theme.muted)]
|
|
917
|
+
text, problem = self._content()
|
|
918
|
+
if problem:
|
|
919
|
+
out.append(self.paint(problem, self.theme.muted))
|
|
920
|
+
return out
|
|
921
|
+
lines = text.split('\n')[:self.max_lines]
|
|
922
|
+
ext = os.path.splitext(name)[1].lower()
|
|
923
|
+
if ext in ('.md', '.markdown'):
|
|
924
|
+
from .display import MarkdownView
|
|
925
|
+
body = MarkdownView('\n'.join(lines), theme=self.theme,
|
|
926
|
+
use_colors=self.use_colors)
|
|
927
|
+
else:
|
|
928
|
+
body = Code('\n'.join(lines), self.LANGUAGES.get(ext),
|
|
929
|
+
theme=self.theme, use_colors=self.use_colors)
|
|
930
|
+
out.extend(body.render(width=width).split('\n'))
|
|
931
|
+
return out
|