flexablelib-components 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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