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,1543 @@
1
+ """
2
+ Form inputs: text, password, numbers, arithmetic, money, masked text,
3
+ multi-line text, JSON, code scanners, checkboxes, switches, radio and
4
+ checkbox groups, selects, autocomplete, colours, icons, coordinates and files.
5
+
6
+ All of them are :class:`~flexablelib_components.base.Input` subclasses: a
7
+ ``label`` above, ``help`` and validation errors below, ``value`` /
8
+ ``set_value()``, ``validate()``, ``on_change`` and ``on_submit``.
9
+ """
10
+
11
+ import ast
12
+ import json
13
+ import operator
14
+ import os
15
+ import re
16
+ import time
17
+
18
+ from flexablelib import _frames as _fx
19
+
20
+ from .base import Input
21
+
22
+ __all__ = ['TextInput', 'PasswordInput', 'NumberInput', 'ExpressionInput',
23
+ 'MoneyInput', 'MaskedInput', 'MASKS', 'TextArea', 'JsonInput',
24
+ 'ScanInput', 'Checkbox', 'Switch', 'RadioGroup', 'CheckboxGroup',
25
+ 'Select', 'Autocomplete', 'ColorInput', 'IconPicker',
26
+ 'CoordinatesInput', 'FileInput', 'normalize_options',
27
+ 'evaluate_expression', 'ICONS']
28
+
29
+ _WORD = re.compile(r'\w', re.UNICODE)
30
+
31
+
32
+ def normalize_options(options):
33
+ """``[(value, label), ...]`` from strings, pairs, dicts or a mapping."""
34
+ if isinstance(options, dict):
35
+ return [(k, str(v)) for k, v in options.items()]
36
+ out = []
37
+ for option in options or []:
38
+ if isinstance(option, dict):
39
+ out.append((option.get('value'), str(option.get(
40
+ 'label', option.get('value')))))
41
+ elif isinstance(option, (tuple, list)):
42
+ out.append((option[0], str(option[1] if len(option) > 1
43
+ else option[0])))
44
+ else:
45
+ out.append((option, str(option)))
46
+ return out
47
+
48
+
49
+ # +---------------------------------------------------------------------------+
50
+ # Single-line text
51
+
52
+ class TextInput(Input):
53
+ """
54
+ One line of editable text.
55
+
56
+ Keys: arrows, Home/End (Ctrl-A/E), Ctrl-arrows by word, Backspace/Delete,
57
+ Ctrl-W (word back), Ctrl-U / Ctrl-K (to start / end), paste, Enter
58
+ submits. ``placeholder`` shows while empty, ``max_length`` caps the
59
+ length, ``allowed`` (a regex character class or callable) filters typed
60
+ characters, ``transform`` is ``'upper'``, ``'lower'`` or a callable.
61
+ """
62
+
63
+ def __init__(self, label=None, value='', placeholder='', max_length=None,
64
+ allowed=None, transform=None, mask_char=None, **kwargs):
65
+ self.text = ''
66
+ self.cursor_pos = 0
67
+ self.scroll_x = 0
68
+ self.placeholder = placeholder
69
+ self.max_length = max_length
70
+ self.allowed = re.compile(allowed) if isinstance(allowed, str) \
71
+ else allowed
72
+ self.transform = transform
73
+ self.mask_char = mask_char
74
+ super(TextInput, self).__init__(label=label, value=value, **kwargs)
75
+
76
+ # value <-> text
77
+ def parse(self, text):
78
+ return text
79
+
80
+ def format(self, value):
81
+ return '' if value is None else str(value)
82
+
83
+ @property
84
+ def value(self):
85
+ try:
86
+ return self.parse(self.text)
87
+ except ValueError:
88
+ return None
89
+
90
+ @value.setter
91
+ def value(self, new):
92
+ self.text = self.format(new)
93
+ self.cursor_pos = len(self.text)
94
+
95
+ def is_empty(self):
96
+ return self.text.strip() == ''
97
+
98
+ def validate(self):
99
+ if self.text.strip():
100
+ try:
101
+ self.parse(self.text)
102
+ except ValueError as error:
103
+ self.error = str(error) or 'Valor inválido'
104
+ return self.error
105
+ return super(TextInput, self).validate()
106
+
107
+ # editing
108
+ def _accept(self, char):
109
+ if self.allowed is None:
110
+ return True
111
+ if callable(self.allowed) and not hasattr(self.allowed, 'match'):
112
+ return bool(self.allowed(char))
113
+ return bool(self.allowed.match(char))
114
+
115
+ def _apply(self, text):
116
+ if self.transform == 'upper':
117
+ return text.upper()
118
+ if self.transform == 'lower':
119
+ return text.lower()
120
+ if callable(self.transform):
121
+ return self.transform(text)
122
+ return text
123
+
124
+ def insert(self, chars):
125
+ chars = ''.join(c for c in chars if c >= ' ' and self._accept(c))
126
+ chars = self._apply(chars)
127
+ if self.max_length is not None:
128
+ chars = chars[:max(0, self.max_length - len(self.text))]
129
+ if not chars:
130
+ return False
131
+ self._edit(self.text[:self.cursor_pos] + chars +
132
+ self.text[self.cursor_pos:], self.cursor_pos + len(chars))
133
+ return True
134
+
135
+ def _edit(self, text, cursor):
136
+ old = self.value
137
+ self.text = text
138
+ self.cursor_pos = max(0, min(cursor, len(text)))
139
+ self.error = None
140
+ new = self.value
141
+ if new != old and self.on_change is not None:
142
+ self.on_change(new)
143
+ self.refresh()
144
+
145
+ def _word_left(self):
146
+ i = self.cursor_pos
147
+ while i > 0 and not _WORD.match(self.text[i - 1]):
148
+ i -= 1
149
+ while i > 0 and _WORD.match(self.text[i - 1]):
150
+ i -= 1
151
+ return i
152
+
153
+ def _word_right(self):
154
+ i, n = self.cursor_pos, len(self.text)
155
+ while i < n and not _WORD.match(self.text[i]):
156
+ i += 1
157
+ while i < n and _WORD.match(self.text[i]):
158
+ i += 1
159
+ return i
160
+
161
+ def on_key(self, key):
162
+ k = str(key)
163
+ pos, text = self.cursor_pos, self.text
164
+ if k == 'paste':
165
+ return self.insert(key.char.replace('\r', '').replace('\n', ' '))
166
+ if key.is_printable or k == 'space':
167
+ return self.insert(key.char or ' ')
168
+ if k == 'backspace':
169
+ if pos > 0:
170
+ self._edit(text[:pos - 1] + text[pos:], pos - 1)
171
+ return True
172
+ if k in ('delete', 'ctrl+d'):
173
+ if pos < len(text):
174
+ self._edit(text[:pos] + text[pos + 1:], pos)
175
+ return True
176
+ if k in ('left', 'ctrl+b'):
177
+ self.cursor_pos = max(0, pos - 1)
178
+ elif k in ('right', 'ctrl+f'):
179
+ self.cursor_pos = min(len(text), pos + 1)
180
+ elif k in ('home', 'ctrl+a'):
181
+ self.cursor_pos = 0
182
+ elif k in ('end', 'ctrl+e'):
183
+ self.cursor_pos = len(text)
184
+ elif k in ('ctrl+left', 'alt+b'):
185
+ self.cursor_pos = self._word_left()
186
+ elif k in ('ctrl+right', 'alt+f'):
187
+ self.cursor_pos = self._word_right()
188
+ elif k in ('ctrl+w', 'alt+backspace', 'ctrl+backspace'):
189
+ start = self._word_left()
190
+ self._edit(text[:start] + text[pos:], start)
191
+ elif k == 'ctrl+u':
192
+ self._edit(text[pos:], 0)
193
+ elif k == 'ctrl+k':
194
+ self._edit(text[:pos], pos)
195
+ elif k == 'enter':
196
+ return self.submit()
197
+ else:
198
+ return False
199
+ self.refresh()
200
+ return True
201
+
202
+ # drawing
203
+ def shown_text(self):
204
+ if self.mask_char:
205
+ return self.mask_char * len(self.text)
206
+ return self.text
207
+
208
+ def suffix(self):
209
+ """Muted text drawn at the right end of the control."""
210
+ return ''
211
+
212
+ def control(self, width, height):
213
+ marker = self.marker()
214
+ suffix = self.suffix()
215
+ avail = max(1, width - 2 - (_fx.visible_width(suffix) + 1
216
+ if suffix else 0))
217
+ shown = self.shown_text()
218
+ if not shown and not self.focused:
219
+ if self.placeholder:
220
+ body = self.paint(_fx.truncate(self.placeholder, avail, '…'),
221
+ self.theme.muted)
222
+ else:
223
+ body = '' if self._active() else '_' * min(avail, 24)
224
+ else:
225
+ body = self._viewport(shown, avail)
226
+ body = _fx.fit_line(body, avail)
227
+ if self._active():
228
+ body = self.paint(body, self.theme.field)
229
+ line = marker + ' ' + body
230
+ if suffix:
231
+ line += ' ' + self.paint(suffix, self.theme.muted)
232
+ return [line]
233
+
234
+ def _viewport(self, shown, avail):
235
+ widths = [_fx.visible_width(c) for c in shown]
236
+ cursor_col = sum(widths[:self.cursor_pos])
237
+ if cursor_col < self.scroll_x:
238
+ self.scroll_x = cursor_col
239
+ if cursor_col >= self.scroll_x + avail:
240
+ self.scroll_x = cursor_col - avail + 1
241
+ out, col = [], 0
242
+ for i, (char, w) in enumerate(zip(shown, widths)):
243
+ if col >= self.scroll_x and col + w <= self.scroll_x + avail:
244
+ out.append(self.cursor(char) if self.focused and
245
+ i == self.cursor_pos else char)
246
+ col += w
247
+ if self.focused and self.cursor_pos >= len(shown) and \
248
+ cursor_col - self.scroll_x < avail:
249
+ out.append(self.cursor(' ') if self._active() else '_')
250
+ if not shown and self.placeholder and self.focused:
251
+ out.append(self.paint(self.placeholder, self.theme.muted))
252
+ return ''.join(out)
253
+
254
+
255
+ class PasswordInput(TextInput):
256
+ """Hidden text; Ctrl-R or F2 shows and hides it."""
257
+
258
+ def __init__(self, label=None, value='', mask_char='•', **kwargs):
259
+ super(PasswordInput, self).__init__(label=label, value=value,
260
+ mask_char=mask_char, **kwargs)
261
+ self._mask = mask_char
262
+ self.revealed = False
263
+
264
+ def on_key(self, key):
265
+ if str(key) in ('ctrl+r', 'f2'):
266
+ self.revealed = not self.revealed
267
+ self.mask_char = None if self.revealed else self._mask
268
+ self.refresh()
269
+ return True
270
+ return super(PasswordInput, self).on_key(key)
271
+
272
+ def suffix(self):
273
+ return 'ctrl+r' if self.focused else ''
274
+
275
+
276
+ class NumberInput(TextInput):
277
+ """
278
+ A number. Up/Down step it by ``step`` within ``minimum`` / ``maximum``;
279
+ ``decimals`` fixes the decimals shown (``0`` gives an int).
280
+ """
281
+
282
+ def __init__(self, label=None, value=None, minimum=None, maximum=None,
283
+ step=1, decimals=None, **kwargs):
284
+ self.minimum = minimum
285
+ self.maximum = maximum
286
+ self.step = step
287
+ self.decimals = decimals
288
+ kwargs.setdefault('allowed', r'[0-9.,\-+eE]')
289
+ super(NumberInput, self).__init__(label=label, value=value, **kwargs)
290
+
291
+ def parse(self, text):
292
+ text = text.strip().replace(',', '')
293
+ if not text:
294
+ return None
295
+ try:
296
+ number = float(text)
297
+ except ValueError:
298
+ raise ValueError('No es un número')
299
+ if self.decimals == 0 or (self.decimals is None and number.is_integer()
300
+ and 'e' not in text.lower()
301
+ and '.' not in text):
302
+ number = int(round(number))
303
+ if self.minimum is not None and number < self.minimum:
304
+ raise ValueError('Mínimo {0}'.format(self.minimum))
305
+ if self.maximum is not None and number > self.maximum:
306
+ raise ValueError('Máximo {0}'.format(self.maximum))
307
+ return number
308
+
309
+ def format(self, value):
310
+ if value is None or value == '':
311
+ return ''
312
+ if self.decimals is not None:
313
+ return '{0:.{1}f}'.format(float(value), self.decimals)
314
+ return str(value)
315
+
316
+ def _clamp(self, number):
317
+ if self.minimum is not None:
318
+ number = max(self.minimum, number)
319
+ if self.maximum is not None:
320
+ number = min(self.maximum, number)
321
+ return number
322
+
323
+ def on_key(self, key):
324
+ k = str(key)
325
+ if k in ('up', 'down', 'pageup', 'pagedown'):
326
+ factor = {'up': 1, 'down': -1, 'pageup': 10, 'pagedown': -10}[k]
327
+ current = self.value or 0
328
+ number = self._clamp(current + factor * self.step)
329
+ if isinstance(number, float) and self.decimals is None:
330
+ number = round(number, 10)
331
+ self._edit(self.format(number), len(self.format(number)))
332
+ return True
333
+ return super(NumberInput, self).on_key(key)
334
+
335
+
336
+ _OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
337
+ ast.Div: operator.truediv, ast.Mod: operator.mod,
338
+ ast.Pow: operator.pow, ast.FloorDiv: operator.floordiv}
339
+ _UNARY = {ast.UAdd: operator.pos, ast.USub: operator.neg}
340
+
341
+
342
+ def evaluate_expression(text):
343
+ """
344
+ The value of an arithmetic expression (``+ - * / // % ** ^``, brackets,
345
+ decimals, ``,`` as thousands separator). Nothing else is evaluated.
346
+ """
347
+ source = text.replace('^', '**').replace(',', '').strip()
348
+ if not source:
349
+ return None
350
+ try:
351
+ tree = ast.parse(source, mode='eval')
352
+ except SyntaxError:
353
+ raise ValueError('Operación inválida')
354
+
355
+ def walk(node):
356
+ if isinstance(node, ast.Expression):
357
+ return walk(node.body)
358
+ if isinstance(node, ast.Constant) and \
359
+ isinstance(node.value, (int, float)) and \
360
+ not isinstance(node.value, bool):
361
+ return node.value
362
+ if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
363
+ left, right = walk(node.left), walk(node.right)
364
+ if isinstance(node.op, ast.Pow) and abs(right) > 100:
365
+ raise ValueError('Exponente muy grande')
366
+ try:
367
+ return _OPS[type(node.op)](left, right)
368
+ except ZeroDivisionError:
369
+ raise ValueError('División entre cero')
370
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY:
371
+ return _UNARY[type(node.op)](walk(node.operand))
372
+ raise ValueError('Operación inválida')
373
+ result = walk(tree)
374
+ if isinstance(result, float) and result.is_integer() and \
375
+ abs(result) < 1e15:
376
+ result = int(result)
377
+ return result
378
+
379
+
380
+ class ExpressionInput(NumberInput):
381
+ """
382
+ A number that also takes arithmetic (``2*3+1``, ``(120-15)/4``): the
383
+ result shows at the right while typing and replaces the expression on
384
+ Enter or when the focus leaves.
385
+ """
386
+
387
+ def __init__(self, label=None, value=None, **kwargs):
388
+ kwargs.setdefault('allowed', r'[0-9.,+\-*/%^() eE]')
389
+ super(ExpressionInput, self).__init__(label=label, value=value,
390
+ **kwargs)
391
+
392
+ def parse(self, text):
393
+ result = evaluate_expression(text)
394
+ if result is None:
395
+ return None
396
+ return super(ExpressionInput, self).parse(repr(result))
397
+
398
+ def suffix(self):
399
+ if not re.search(r'[+\-*/%^()]', self.text.lstrip('-')):
400
+ return ''
401
+ value = self.value
402
+ return '' if value is None else '= ' + self.format(value)
403
+
404
+ def resolve(self):
405
+ value = self.value
406
+ if value is not None:
407
+ text = self.format(value)
408
+ self.text, self.cursor_pos = text, len(text)
409
+
410
+ def on_blur(self):
411
+ self.resolve()
412
+
413
+ def submit(self):
414
+ self.resolve()
415
+ return super(ExpressionInput, self).submit()
416
+
417
+
418
+ class MoneyInput(NumberInput):
419
+ """An amount: ``symbol`` before it, thousands separators when not
420
+ editing, ``decimals`` (2) after the point."""
421
+
422
+ def __init__(self, label=None, value=None, symbol='$', decimals=2,
423
+ **kwargs):
424
+ self.symbol = symbol
425
+ super(MoneyInput, self).__init__(label=label, value=value,
426
+ decimals=decimals, **kwargs)
427
+
428
+ def parse(self, text):
429
+ return super(MoneyInput, self).parse(text.replace(self.symbol, ''))
430
+
431
+ def shown_text(self):
432
+ if self.focused:
433
+ return self.symbol + self.text
434
+ value = self.value
435
+ if value is None:
436
+ return ''
437
+ return '{0}{1:,.{2}f}'.format(self.symbol, value, self.decimals or 0)
438
+
439
+ def _viewport(self, shown, avail):
440
+ # The symbol is not part of the editable text: shift the cursor.
441
+ saved = self.cursor_pos
442
+ self.cursor_pos = saved + (len(self.symbol) if self.focused else 0)
443
+ try:
444
+ return super(MoneyInput, self)._viewport(shown, avail)
445
+ finally:
446
+ self.cursor_pos = saved
447
+
448
+
449
+ MASKS = {
450
+ 'phone': '(###) ###-####',
451
+ 'postal_code': '#####',
452
+ 'date': '##/##/####',
453
+ 'time': '##:##',
454
+ 'card': '#### #### #### ####',
455
+ 'rfc': 'AAAA######XXX',
456
+ 'curp': 'AAAA######AAAAAAXX',
457
+ }
458
+ _MASK_SLOTS = {'#': str.isdigit, 'A': str.isalpha,
459
+ 'X': str.isalnum, '*': lambda c: True}
460
+
461
+
462
+ class MaskedInput(TextInput):
463
+ """
464
+ Text with a fixed pattern: ``#`` a digit, ``A`` a letter, ``X`` either,
465
+ ``*`` anything; other characters are literals filled in automatically.
466
+ ``mask`` may be a name from :data:`MASKS` (``'phone'``, ``'rfc'`` ...).
467
+ ``value`` is the formatted text (``raw=True``: only what was typed).
468
+ """
469
+
470
+ def __init__(self, label=None, value='', mask='phone', raw=False,
471
+ transform='upper', **kwargs):
472
+ self.mask = MASKS.get(mask, mask)
473
+ self.raw = bool(raw)
474
+ self.slots = [i for i, c in enumerate(self.mask) if c in _MASK_SLOTS]
475
+ super(MaskedInput, self).__init__(label=label, value=value,
476
+ transform=transform,
477
+ max_length=len(self.slots),
478
+ **kwargs)
479
+
480
+ def _accept(self, char):
481
+ if len(self.text) >= len(self.slots):
482
+ return False
483
+ check = _MASK_SLOTS[self.mask[self.slots[len(self.text)]]]
484
+ return check(char)
485
+
486
+ def insert(self, chars):
487
+ done = False
488
+ for char in chars:
489
+ self.cursor_pos = len(self.text)
490
+ if self._accept(char):
491
+ done = super(MaskedInput, self).insert(char) or done
492
+ return done
493
+
494
+ def format(self, value):
495
+ if value is None:
496
+ return ''
497
+ typed = [c for c in str(value) if c.isalnum()]
498
+ out = []
499
+ for slot, char in zip(self.slots, typed):
500
+ if _MASK_SLOTS[self.mask[slot]](char):
501
+ out.append(char)
502
+ return self._apply(''.join(out))
503
+
504
+ def parse(self, text):
505
+ if self.raw:
506
+ return text
507
+ return self.masked(text, fill=False)
508
+
509
+ def masked(self, text, fill=True):
510
+ out = []
511
+ k = 0
512
+ for i, c in enumerate(self.mask):
513
+ if c in _MASK_SLOTS:
514
+ if k < len(text):
515
+ out.append(text[k])
516
+ k += 1
517
+ elif fill:
518
+ out.append('_')
519
+ else:
520
+ break
521
+ else:
522
+ if k < len(text) or fill:
523
+ out.append(c)
524
+ else:
525
+ break
526
+ return ''.join(out)
527
+
528
+ def validate(self):
529
+ if self.text and len(self.text) < len(self.slots):
530
+ self.error = 'Formato: ' + self.mask
531
+ return self.error
532
+ return super(MaskedInput, self).validate()
533
+
534
+ def shown_text(self):
535
+ return self.masked(self.text)
536
+
537
+ def _viewport(self, shown, avail):
538
+ saved = self.cursor_pos
539
+ k = len(self.text)
540
+ self.cursor_pos = self.slots[k] if k < len(self.slots) else len(shown)
541
+ try:
542
+ return super(MaskedInput, self)._viewport(shown, avail)
543
+ finally:
544
+ self.cursor_pos = saved
545
+
546
+ def on_key(self, key):
547
+ if str(key) in ('left', 'right', 'home', 'end'):
548
+ return True
549
+ self.cursor_pos = len(self.text)
550
+ return super(MaskedInput, self).on_key(key)
551
+
552
+
553
+ # +---------------------------------------------------------------------------+
554
+ # Multi-line text
555
+
556
+ class TextArea(Input):
557
+ """
558
+ Several lines of text, wrapped to the width. Enter starts a new line;
559
+ ``submit_key`` (``'ctrl+s'``) submits. ``rows`` is the visible height.
560
+ """
561
+
562
+ def __init__(self, label=None, value='', rows=5, placeholder='',
563
+ submit_key='ctrl+s', wrap=True, **kwargs):
564
+ self.lines = ['']
565
+ self.row = 0
566
+ self.col = 0
567
+ self.rows = max(1, int(rows))
568
+ self.scroll_y = 0
569
+ self.placeholder = placeholder
570
+ self.submit_key = submit_key
571
+ self.wrap = bool(wrap)
572
+ super(TextArea, self).__init__(label=label, value=value, **kwargs)
573
+ self.row = self.col = 0
574
+
575
+ @property
576
+ def text(self):
577
+ return '\n'.join(self.lines)
578
+
579
+ def parse(self, text):
580
+ return text
581
+
582
+ def format(self, value):
583
+ return '' if value is None else str(value)
584
+
585
+ @property
586
+ def value(self):
587
+ try:
588
+ return self.parse(self.text)
589
+ except ValueError:
590
+ return None
591
+
592
+ @value.setter
593
+ def value(self, new):
594
+ self.lines = self.format(new).split('\n') or ['']
595
+ self.row = len(self.lines) - 1
596
+ self.col = len(self.lines[-1])
597
+
598
+ def is_empty(self):
599
+ return not self.text.strip()
600
+
601
+ def validate(self):
602
+ if self.text.strip():
603
+ try:
604
+ self.parse(self.text)
605
+ except ValueError as error:
606
+ self.error = str(error) or 'Valor inválido'
607
+ return self.error
608
+ return super(TextArea, self).validate()
609
+
610
+ def _changed(self):
611
+ self.error = None
612
+ if self.on_change is not None:
613
+ self.on_change(self.value)
614
+ self.refresh()
615
+
616
+ def insert(self, text):
617
+ line = self.lines[self.row]
618
+ before, after = line[:self.col], line[self.col:]
619
+ parts = (before + text + after).split('\n')
620
+ last_len = len(parts[-1]) - len(after)
621
+ self.lines[self.row:self.row + 1] = parts
622
+ self.row += len(parts) - 1
623
+ self.col = last_len
624
+ self._changed()
625
+ return True
626
+
627
+ def on_key(self, key):
628
+ k = str(key)
629
+ line = self.lines[self.row]
630
+ if k == self.submit_key:
631
+ return self.submit()
632
+ if k == 'paste':
633
+ return self.insert(key.char.replace('\r', ''))
634
+ if k == 'enter':
635
+ return self.insert('\n')
636
+ if key.is_printable or k == 'space':
637
+ return self.insert(key.char or ' ')
638
+ if k == 'backspace':
639
+ if self.col > 0:
640
+ self.lines[self.row] = line[:self.col - 1] + line[self.col:]
641
+ self.col -= 1
642
+ elif self.row > 0:
643
+ prev = self.lines[self.row - 1]
644
+ self.lines[self.row - 1:self.row + 1] = [prev + line]
645
+ self.row -= 1
646
+ self.col = len(prev)
647
+ self._changed()
648
+ return True
649
+ if k == 'delete':
650
+ if self.col < len(line):
651
+ self.lines[self.row] = line[:self.col] + line[self.col + 1:]
652
+ elif self.row + 1 < len(self.lines):
653
+ self.lines[self.row:self.row + 2] = [
654
+ line + self.lines[self.row + 1]]
655
+ self._changed()
656
+ return True
657
+ if k == 'left':
658
+ if self.col > 0:
659
+ self.col -= 1
660
+ elif self.row > 0:
661
+ self.row -= 1
662
+ self.col = len(self.lines[self.row])
663
+ elif k == 'right':
664
+ if self.col < len(line):
665
+ self.col += 1
666
+ elif self.row + 1 < len(self.lines):
667
+ self.row += 1
668
+ self.col = 0
669
+ elif k == 'up':
670
+ if self.row == 0:
671
+ return False
672
+ self.row -= 1
673
+ self.col = min(self.col, len(self.lines[self.row]))
674
+ elif k == 'down':
675
+ if self.row + 1 >= len(self.lines):
676
+ return False
677
+ self.row += 1
678
+ self.col = min(self.col, len(self.lines[self.row]))
679
+ elif k in ('home', 'ctrl+a'):
680
+ self.col = 0
681
+ elif k in ('end', 'ctrl+e'):
682
+ self.col = len(line)
683
+ elif k == 'ctrl+home':
684
+ self.row = self.col = 0
685
+ elif k == 'ctrl+end':
686
+ self.row = len(self.lines) - 1
687
+ self.col = len(self.lines[-1])
688
+ elif k == 'pageup':
689
+ self.row = max(0, self.row - self.rows)
690
+ self.col = min(self.col, len(self.lines[self.row]))
691
+ elif k == 'pagedown':
692
+ self.row = min(len(self.lines) - 1, self.row + self.rows)
693
+ self.col = min(self.col, len(self.lines[self.row]))
694
+ else:
695
+ return False
696
+ self.refresh()
697
+ return True
698
+
699
+ def _segments(self, width):
700
+ """``(row, start, end)`` for every visual line."""
701
+ out = []
702
+ for r, line in enumerate(self.lines):
703
+ if not self.wrap or not line:
704
+ out.append((r, 0, len(line)))
705
+ continue
706
+ start, col = 0, 0
707
+ for i, ch in enumerate(line):
708
+ w = _fx.visible_width(ch)
709
+ if col + w > width:
710
+ out.append((r, start, i))
711
+ start, col = i, 0
712
+ col += w
713
+ out.append((r, start, len(line)))
714
+ return out
715
+
716
+ def decorate(self, row, text):
717
+ """Hook for syntax colouring of one logical line's segment."""
718
+ return text
719
+
720
+ def gutter(self, row, first):
721
+ return ''
722
+
723
+ def control(self, width, height):
724
+ marker = self.marker()
725
+ gutter_w = _fx.visible_width(self.gutter(len(self.lines) - 1, True))
726
+ avail = max(1, width - 2 - gutter_w)
727
+ segs = self._segments(avail)
728
+ cur = 0
729
+ for i, (r, s, e) in enumerate(segs):
730
+ if r == self.row and s <= self.col <= e:
731
+ cur = i
732
+ if self.col < e or (i + 1 < len(segs) and segs[i + 1][0] != r) \
733
+ or i + 1 == len(segs):
734
+ break
735
+ if cur < self.scroll_y:
736
+ self.scroll_y = cur
737
+ if cur >= self.scroll_y + self.rows:
738
+ self.scroll_y = cur - self.rows + 1
739
+ out = []
740
+ if not self.text and not self.focused and self.placeholder:
741
+ out.append(marker + ' ' + self.paint(self.placeholder,
742
+ self.theme.muted))
743
+ else:
744
+ for i in range(self.scroll_y, min(len(segs), self.scroll_y +
745
+ self.rows)):
746
+ r, s, e = segs[i]
747
+ piece = self.lines[r][s:e]
748
+ if self.focused and i == cur:
749
+ c = self.col - s
750
+ char = piece[c] if c < len(piece) else ' '
751
+ shown = self.decorate(r, piece[:c]) + self.cursor(char) + \
752
+ self.decorate(r, piece[c + 1:])
753
+ else:
754
+ shown = self.decorate(r, piece)
755
+ g = self.gutter(r, s == 0)
756
+ out.append(marker + ' ' + g + shown)
757
+ while len(out) < self.rows:
758
+ out.append(marker + ' ')
759
+ return out
760
+
761
+
762
+ class JsonInput(TextArea):
763
+ """JSON text; the value is the parsed object. Ctrl-F reformats."""
764
+
765
+ def __init__(self, label=None, value=None, indent=2, **kwargs):
766
+ self.indent = indent
767
+ super(JsonInput, self).__init__(label=label, value=value, **kwargs)
768
+
769
+ def parse(self, text):
770
+ if not text.strip():
771
+ return None
772
+ try:
773
+ return json.loads(text)
774
+ except ValueError as error:
775
+ raise ValueError('JSON inválido: {0}'.format(
776
+ getattr(error, 'msg', error)))
777
+
778
+ def format(self, value):
779
+ if value is None:
780
+ return ''
781
+ if isinstance(value, str):
782
+ return value
783
+ return json.dumps(value, indent=self.indent, ensure_ascii=False)
784
+
785
+ def on_key(self, key):
786
+ if str(key) == 'ctrl+f':
787
+ try:
788
+ self.value = self.parse(self.text)
789
+ except ValueError as error:
790
+ self.error = str(error)
791
+ self.refresh()
792
+ return True
793
+ return super(JsonInput, self).on_key(key)
794
+
795
+
796
+ class ScanInput(TextInput):
797
+ """
798
+ Codes from a keyboard-wedge barcode/QR reader (or typed): each Enter
799
+ accepts one code -- cleaned with ``strip_prefix`` / ``strip_suffix`` /
800
+ ``transform``, checked against ``pattern`` and ``min_length`` /
801
+ ``max_length``, repeated reads within ``duplicate_ms`` dropped,
802
+ ``unique=True`` refusing codes already read -- then calls
803
+ ``on_scan(code)`` and clears for the next one. ``scans`` keeps them.
804
+ """
805
+
806
+ def __init__(self, label=None, on_scan=None, pattern=None,
807
+ min_length=None, max_length=None, strip_prefix='',
808
+ strip_suffix='', unique=False, duplicate_ms=800,
809
+ history_size=50, clock=time.monotonic, **kwargs):
810
+ self.on_scan = on_scan
811
+ self.pattern = re.compile(pattern) if pattern else None
812
+ self.min_length = min_length
813
+ self.max_code = max_length
814
+ self.strip_prefix = strip_prefix
815
+ self.strip_suffix = strip_suffix
816
+ self.unique = bool(unique)
817
+ self.duplicate_ms = duplicate_ms
818
+ self.history_size = history_size
819
+ self.clock = clock
820
+ self.scans = []
821
+ self._last = (None, 0.0)
822
+ kwargs.setdefault('placeholder', 'Escanea o escribe un código')
823
+ super(ScanInput, self).__init__(label=label, **kwargs)
824
+
825
+ def clean(self, code):
826
+ code = code.strip()
827
+ if self.strip_prefix and code.startswith(self.strip_prefix):
828
+ code = code[len(self.strip_prefix):]
829
+ if self.strip_suffix and code.endswith(self.strip_suffix):
830
+ code = code[:-len(self.strip_suffix)]
831
+ return self._apply(code.replace(' ', '') if self.transform ==
832
+ 'nospace' else code)
833
+
834
+ def check(self, code):
835
+ if not code:
836
+ return 'Código vacío'
837
+ if self.min_length and len(code) < self.min_length:
838
+ return 'Mínimo {0} caracteres'.format(self.min_length)
839
+ if self.max_code and len(code) > self.max_code:
840
+ return 'Máximo {0} caracteres'.format(self.max_code)
841
+ if self.pattern and not self.pattern.fullmatch(code):
842
+ return 'Formato no válido'
843
+ if self.unique and code in self.scans:
844
+ return 'Ya leído'
845
+ return None
846
+
847
+ def submit(self):
848
+ code = self.clean(self.text)
849
+ now = self.clock()
850
+ last_code, last_time = self._last
851
+ if code == last_code and (now - last_time) * 1000 < self.duplicate_ms:
852
+ self._edit('', 0)
853
+ return True
854
+ self.error = self.check(code)
855
+ if self.error is None:
856
+ self.scans.append(code)
857
+ del self.scans[:-self.history_size]
858
+ self._last = (code, now)
859
+ if self.on_scan is not None:
860
+ self.on_scan(code)
861
+ self.text, self.cursor_pos = '', 0
862
+ self.refresh()
863
+ return True
864
+
865
+ def suffix(self):
866
+ return '{0} leídos'.format(len(self.scans)) if self.scans else ''
867
+
868
+
869
+ # +---------------------------------------------------------------------------+
870
+ # Choices
871
+
872
+ class Checkbox(Input):
873
+ """``[x] label``: Space or Enter toggles."""
874
+
875
+ def __init__(self, label=None, value=False, **kwargs):
876
+ super(Checkbox, self).__init__(label=label, value=value, **kwargs)
877
+
878
+ def coerce(self, value):
879
+ return bool(value)
880
+
881
+ def is_empty(self):
882
+ return not self._value
883
+
884
+ def on_key(self, key):
885
+ if str(key) in ('space', 'enter'):
886
+ self.set_value(not self._value)
887
+ return True
888
+ return False
889
+
890
+ def draw(self, width, height):
891
+ box = '[x]' if self._value else '[ ]'
892
+ if self._active():
893
+ box = '☑' if self._value else '☐'
894
+ spec = self.theme.accent if self.focused else None
895
+ line = self.marker() + ' ' + self.paint(box, spec) + ' ' + \
896
+ (self.label or '') + ('*' if self.required else '')
897
+ out = [line]
898
+ if self.error:
899
+ out.append(self.paint('✖ ' + self.error, self.theme.error))
900
+ elif self.help:
901
+ out.append(self.paint(' ' + self.help, self.theme.muted))
902
+ return out
903
+
904
+
905
+ class Switch(Checkbox):
906
+ """An on/off switch; ``labels`` names the two states."""
907
+
908
+ def __init__(self, label=None, value=False, labels=('No', 'Sí'),
909
+ **kwargs):
910
+ self.labels = labels
911
+ super(Switch, self).__init__(label=label, value=value, **kwargs)
912
+
913
+ def draw(self, width, height):
914
+ on = self._value
915
+ if self._active():
916
+ track = self.paint(' ━━●', self.theme.success) if on else \
917
+ self.paint('●━━ ', self.theme.muted)
918
+ else:
919
+ track = '[ on]' if on else '[off]'
920
+ state = self.labels[1] if on else self.labels[0]
921
+ label = self.paint(self.label or '', self.theme.accent
922
+ if self.focused else None)
923
+ return [self.marker() + ' ' + label + ' ' + track + ' ' + state]
924
+
925
+
926
+ class RadioGroup(Input):
927
+ """
928
+ One of ``options``. ``layout='vertical'`` lists them (Up/Down), ``'buttons'``
929
+ draws a segmented row (Left/Right). Moving selects.
930
+ """
931
+
932
+ def __init__(self, label=None, options=(), value=None,
933
+ layout='vertical', **kwargs):
934
+ self.options = normalize_options(options)
935
+ self.layout = layout
936
+ self.index = 0
937
+ super(RadioGroup, self).__init__(label=label, value=value, **kwargs)
938
+ values = [v for v, _ in self.options]
939
+ if self._value in values:
940
+ self.index = values.index(self._value)
941
+
942
+ def coerce(self, value):
943
+ return value
944
+
945
+ def _move(self, step):
946
+ if not self.options:
947
+ return True
948
+ self.index = (self.index + step) % len(self.options)
949
+ self.set_value(self.options[self.index][0])
950
+ return True
951
+
952
+ def on_key(self, key):
953
+ k = str(key)
954
+ horizontal = self.layout == 'buttons'
955
+ back = ('left',) if horizontal else ('up',)
956
+ fwd = ('right',) if horizontal else ('down',)
957
+ if k in back:
958
+ return self._move(-1)
959
+ if k in fwd:
960
+ return self._move(1)
961
+ if k in ('space', 'enter') and self.options:
962
+ self.set_value(self.options[self.index][0])
963
+ return True
964
+ if len(k) == 1:
965
+ for i, (_, label) in enumerate(self.options):
966
+ if label[:1].lower() == k.lower():
967
+ self.index = i
968
+ self.set_value(self.options[i][0])
969
+ return True
970
+ return False
971
+
972
+ def control(self, width, height):
973
+ if self.layout == 'buttons':
974
+ parts = []
975
+ for i, (value, label) in enumerate(self.options):
976
+ chosen = value == self._value
977
+ spec = (self.theme.selection if self.focused else
978
+ 'reverse') if chosen else 'on bright_black'
979
+ text = ' {0} '.format(label)
980
+ if not self._active():
981
+ text = '[{0}]'.format(label) if chosen else \
982
+ ' {0} '.format(label)
983
+ parts.append(self.paint(text, spec))
984
+ return [self.marker() + ' ' + ''.join(parts)]
985
+ out = []
986
+ for i, (value, label) in enumerate(self.options):
987
+ dot = '(•)' if value == self._value else '( )'
988
+ if self._active():
989
+ dot = '◉' if value == self._value else '○'
990
+ here = self.focused and i == self.index
991
+ out.append((self.marker() if here else ' ') + ' ' +
992
+ self.paint(dot + ' ' + label,
993
+ self.theme.accent if here else None))
994
+ return out
995
+
996
+
997
+ class CheckboxGroup(RadioGroup):
998
+ """Several of ``options``: Up/Down move, Space toggles. Value: a list."""
999
+
1000
+ def coerce(self, value):
1001
+ return list(value or [])
1002
+
1003
+ def on_key(self, key):
1004
+ k = str(key)
1005
+ if k == 'up' and self.options:
1006
+ self.index = (self.index - 1) % len(self.options)
1007
+ elif k == 'down' and self.options:
1008
+ self.index = (self.index + 1) % len(self.options)
1009
+ elif k in ('space', 'enter') and self.options:
1010
+ value = self.options[self.index][0]
1011
+ chosen = list(self._value)
1012
+ if value in chosen:
1013
+ chosen.remove(value)
1014
+ else:
1015
+ chosen.append(value)
1016
+ order = [v for v, _ in self.options]
1017
+ self.set_value(sorted(chosen, key=order.index))
1018
+ elif k == 'ctrl+a':
1019
+ self.set_value([v for v, _ in self.options])
1020
+ else:
1021
+ return False
1022
+ self.refresh()
1023
+ return True
1024
+
1025
+ def control(self, width, height):
1026
+ out = []
1027
+ for i, (value, label) in enumerate(self.options):
1028
+ on = value in self._value
1029
+ box = ('☑' if on else '☐') if self._active() else \
1030
+ ('[x]' if on else '[ ]')
1031
+ here = self.focused and i == self.index
1032
+ out.append((self.marker() if here else ' ') + ' ' +
1033
+ self.paint(box + ' ' + label,
1034
+ self.theme.accent if here else None))
1035
+ return out
1036
+
1037
+
1038
+ class Select(Input):
1039
+ """
1040
+ A drop-down list. Enter or Space opens it; typing filters; Up/Down move;
1041
+ Enter picks; Escape closes. ``max_visible`` rows show at once.
1042
+ """
1043
+
1044
+ def __init__(self, label=None, options=(), value=None, max_visible=6,
1045
+ placeholder='Selecciona…', **kwargs):
1046
+ self.options = normalize_options(options)
1047
+ self.max_visible = max(1, int(max_visible))
1048
+ self.placeholder = placeholder
1049
+ self.open = False
1050
+ self.query = ''
1051
+ self.index = 0
1052
+ self.top = 0
1053
+ super(Select, self).__init__(label=label, value=value, **kwargs)
1054
+
1055
+ def label_of(self, value):
1056
+ for v, label in self.options:
1057
+ if v == value:
1058
+ return label
1059
+ return None if value is None else str(value)
1060
+
1061
+ def matches(self):
1062
+ q = self.query.lower()
1063
+ return [o for o in self.options if q in o[1].lower()]
1064
+
1065
+ def on_blur(self):
1066
+ self.open = False
1067
+
1068
+ def on_key(self, key):
1069
+ k = str(key)
1070
+ if not self.open:
1071
+ if k in ('enter', 'space', 'down', 'alt+down'):
1072
+ self.open = True
1073
+ self.query = ''
1074
+ values = [v for v, _ in self.options]
1075
+ self.index = values.index(self._value) \
1076
+ if self._value in values else 0
1077
+ self.refresh()
1078
+ return True
1079
+ return False
1080
+ items = self.matches()
1081
+ if k == 'escape':
1082
+ self.open = False
1083
+ elif k == 'up':
1084
+ self.index = max(0, self.index - 1)
1085
+ elif k == 'down':
1086
+ self.index = min(max(0, len(items) - 1), self.index + 1)
1087
+ elif k == 'pageup':
1088
+ self.index = max(0, self.index - self.max_visible)
1089
+ elif k == 'pagedown':
1090
+ self.index = min(max(0, len(items) - 1),
1091
+ self.index + self.max_visible)
1092
+ elif k == 'enter':
1093
+ if items:
1094
+ self.set_value(items[min(self.index, len(items) - 1)][0])
1095
+ self.open = False
1096
+ elif k == 'backspace':
1097
+ self.query = self.query[:-1]
1098
+ self.index = 0
1099
+ elif key.is_printable:
1100
+ self.query += key.char
1101
+ self.index = 0
1102
+ else:
1103
+ return k not in ('tab', 'shift+tab')
1104
+ self.refresh()
1105
+ return True
1106
+
1107
+ def control(self, width, height):
1108
+ shown = self.label_of(self._value)
1109
+ arrow = '▴' if self.open else '▾'
1110
+ avail = max(1, width - 4)
1111
+ if shown is None:
1112
+ body = self.paint(self.placeholder, self.theme.muted)
1113
+ else:
1114
+ body = shown
1115
+ line = self.marker() + ' ' + _fx.fit_line(body, avail) + ' ' + \
1116
+ self.paint(arrow, self.theme.accent if self.focused else
1117
+ self.theme.muted)
1118
+ out = [line]
1119
+ if self.open:
1120
+ items = self.matches()
1121
+ if self.query:
1122
+ out.append(' ' + self.paint('/' + self.query,
1123
+ self.theme.accent))
1124
+ if self.index < self.top:
1125
+ self.top = self.index
1126
+ if self.index >= self.top + self.max_visible:
1127
+ self.top = self.index - self.max_visible + 1
1128
+ for i in range(self.top, min(len(items), self.top +
1129
+ self.max_visible)):
1130
+ text = _fx.fit_line(' ' + items[i][1], max(1, width - 2))
1131
+ if i == self.index:
1132
+ text = self.paint(text, self.theme.selection) if \
1133
+ self._active() else '>' + text[1:]
1134
+ out.append(' ' + text)
1135
+ if not items:
1136
+ out.append(' ' + self.paint('Sin resultados',
1137
+ self.theme.muted))
1138
+ return out
1139
+
1140
+
1141
+ class Autocomplete(TextInput):
1142
+ """
1143
+ Text with suggestions from ``options`` (matching anywhere, case- and
1144
+ accent-insensitive). Up/Down choose a suggestion, Enter or Right accepts
1145
+ it. ``strict=True`` only accepts an option's label.
1146
+ """
1147
+
1148
+ def __init__(self, label=None, options=(), value='', max_suggestions=5,
1149
+ strict=False, **kwargs):
1150
+ self.options = normalize_options(options)
1151
+ self.max_suggestions = max(1, int(max_suggestions))
1152
+ self.strict = bool(strict)
1153
+ self.highlight = -1
1154
+ super(Autocomplete, self).__init__(label=label, value=value, **kwargs)
1155
+
1156
+ @staticmethod
1157
+ def _fold(text):
1158
+ import unicodedata
1159
+ return ''.join(c for c in unicodedata.normalize('NFD', text.lower())
1160
+ if unicodedata.category(c) != 'Mn')
1161
+
1162
+ def suggestions(self):
1163
+ q = self._fold(self.text.strip())
1164
+ if not q:
1165
+ return []
1166
+ starts = [o for o in self.options if self._fold(o[1]).startswith(q)]
1167
+ inside = [o for o in self.options if q in self._fold(o[1])
1168
+ and o not in starts]
1169
+ found = starts + inside
1170
+ if len(found) == 1 and found[0][1] == self.text:
1171
+ return []
1172
+ return found[:self.max_suggestions]
1173
+
1174
+ def parse(self, text):
1175
+ for value, label in self.options:
1176
+ if label == text:
1177
+ return value
1178
+ if self.strict and text:
1179
+ raise ValueError('Elige una opción de la lista')
1180
+ return text
1181
+
1182
+ def format(self, value):
1183
+ for v, label in getattr(self, 'options', []):
1184
+ if v == value:
1185
+ return label
1186
+ return super(Autocomplete, self).format(value)
1187
+
1188
+ def on_key(self, key):
1189
+ k = str(key)
1190
+ items = self.suggestions()
1191
+ if items and k in ('up', 'down'):
1192
+ step = 1 if k == 'down' else -1
1193
+ self.highlight = (self.highlight + step) % len(items)
1194
+ self.refresh()
1195
+ return True
1196
+ if items and k in ('enter', 'right') and (
1197
+ self.highlight >= 0 or k == 'enter' or
1198
+ self.cursor_pos == len(self.text)):
1199
+ pick = items[max(0, self.highlight)]
1200
+ self._edit(pick[1], len(pick[1]))
1201
+ self.highlight = -1
1202
+ if k == 'enter':
1203
+ return self.submit()
1204
+ return True
1205
+ if k == 'escape' and items:
1206
+ self.highlight = -1
1207
+ self.text_before = self.text
1208
+ return True
1209
+ self.highlight = -1
1210
+ return super(Autocomplete, self).on_key(key)
1211
+
1212
+ def control(self, width, height):
1213
+ out = super(Autocomplete, self).control(width, height)
1214
+ if self.focused:
1215
+ for i, (_, label) in enumerate(self.suggestions()):
1216
+ text = _fx.fit_line(' ' + label, max(1, width - 2))
1217
+ if i == self.highlight:
1218
+ text = self.paint(text, self.theme.selection) if \
1219
+ self._active() else '>' + text[1:]
1220
+ else:
1221
+ text = self.paint(text, self.theme.muted)
1222
+ out.append(' ' + text)
1223
+ return out
1224
+
1225
+
1226
+ _PALETTE = ['#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c', '#3498db',
1227
+ '#9b59b6', '#34495e', '#95a5a6', '#ffffff', '#000000']
1228
+ _HEX = re.compile(r'^#?([0-9a-fA-F]{6})$')
1229
+
1230
+
1231
+ class ColorInput(TextInput):
1232
+ """A ``#rrggbb`` colour with a swatch; Up/Down walk a palette."""
1233
+
1234
+ def __init__(self, label=None, value='', palette=None, **kwargs):
1235
+ self.palette = list(palette or _PALETTE)
1236
+ kwargs.setdefault('allowed', r'[#0-9a-fA-F]')
1237
+ kwargs.setdefault('max_length', 7)
1238
+ kwargs.setdefault('transform', 'lower')
1239
+ super(ColorInput, self).__init__(label=label, value=value, **kwargs)
1240
+
1241
+ def parse(self, text):
1242
+ if not text:
1243
+ return None
1244
+ match = _HEX.match(text)
1245
+ if not match:
1246
+ raise ValueError('Color #rrggbb')
1247
+ return '#' + match.group(1).lower()
1248
+
1249
+ def suffix(self):
1250
+ value = self.value
1251
+ if value is None:
1252
+ return ' '
1253
+ return '██' if not self._active() else ''
1254
+
1255
+ def control(self, width, height):
1256
+ line = super(ColorInput, self).control(width - 3, height)[0]
1257
+ value = self.value
1258
+ swatch = self.paint('██', value) if value and self._active() else \
1259
+ ' '
1260
+ return [line + ' ' + swatch]
1261
+
1262
+ def on_key(self, key):
1263
+ k = str(key)
1264
+ if k in ('up', 'down'):
1265
+ value = self.value
1266
+ i = self.palette.index(value) if value in self.palette else -1
1267
+ i = (i + (1 if k == 'down' else -1)) % len(self.palette)
1268
+ self._edit(self.palette[i], len(self.palette[i]))
1269
+ return True
1270
+ return super(ColorInput, self).on_key(key)
1271
+
1272
+
1273
+ ICONS = [
1274
+ ('home', '⌂'), ('user', '👤'), ('users', '👥'), ('star', '★'),
1275
+ ('heart', '♥'), ('check', '✔'), ('cross', '✖'), ('warning', '⚠'),
1276
+ ('info', 'ℹ'), ('question', '?'), ('search', '🔍'), ('gear', '⚙'),
1277
+ ('mail', '✉'), ('phone', '☎'), ('calendar', '📅'), ('clock', '🕒'),
1278
+ ('lock', '🔒'), ('unlock', '🔓'), ('key', '🔑'), ('bell', '🔔'),
1279
+ ('folder', '📁'), ('file', '📄'), ('chart', '📈'), ('cart', '🛒'),
1280
+ ('money', '💲'), ('box', '📦'), ('truck', '🚚'), ('tag', '🏷'),
1281
+ ('pin', '📌'), ('flag', '⚑'), ('bolt', '⚡'), ('fire', '🔥'),
1282
+ ('cloud', '☁'), ('sun', '☀'), ('moon', '☾'), ('globe', '🌐'),
1283
+ ('link', '🔗'), ('edit', '✎'), ('trash', '🗑'), ('plus', '+'),
1284
+ ('minus', '-'), ('play', '▶'), ('pause', '⏸'), ('stop', '■'),
1285
+ ('refresh', '↻'), ('up', '↑'), ('down', '↓'), ('left', '←'),
1286
+ ('right', '→'), ('menu', '☰'), ('dot', '●'), ('circle', '○'),
1287
+ ('square', '□'), ('diamond', '◆'), ('triangle', '▲'), ('music', '♪'),
1288
+ ('camera', '📷'), ('print', '🖨'), ('save', '💾'), ('database', '🗄'),
1289
+ ('server', '🖥'), ('code', '⌨'), ('bug', '🐞'), ('shield', '🛡'),
1290
+ ]
1291
+
1292
+
1293
+ class IconPicker(Input):
1294
+ """
1295
+ An icon from a grid (``icons``: ``(name, symbol)`` pairs). Typing filters
1296
+ by name; arrows move; Enter picks. ``value`` is the name.
1297
+ """
1298
+
1299
+ def __init__(self, label=None, value=None, icons=None, columns=8,
1300
+ rows=3, **kwargs):
1301
+ self.icons = list(icons or ICONS)
1302
+ self.columns = max(1, int(columns))
1303
+ self.rows = max(1, int(rows))
1304
+ self.query = ''
1305
+ self.index = 0
1306
+ super(IconPicker, self).__init__(label=label, value=value, **kwargs)
1307
+
1308
+ def matches(self):
1309
+ return [i for i in self.icons if self.query in i[0]]
1310
+
1311
+ def symbol(self):
1312
+ for name, sym in self.icons:
1313
+ if name == self._value:
1314
+ return sym
1315
+ return ''
1316
+
1317
+ def on_key(self, key):
1318
+ k = str(key)
1319
+ items = self.matches()
1320
+ moves = {'left': -1, 'right': 1, 'up': -self.columns,
1321
+ 'down': self.columns}
1322
+ if k in moves and items:
1323
+ self.index = max(0, min(len(items) - 1, self.index + moves[k]))
1324
+ elif k == 'enter' and items:
1325
+ self.set_value(items[min(self.index, len(items) - 1)][0])
1326
+ elif k == 'backspace':
1327
+ self.query = self.query[:-1]
1328
+ self.index = 0
1329
+ elif key.is_printable and k != ' ':
1330
+ self.query += key.char.lower()
1331
+ self.index = 0
1332
+ else:
1333
+ return False
1334
+ self.refresh()
1335
+ return True
1336
+
1337
+ def control(self, width, height):
1338
+ out = [self.marker() + ' ' + (self.symbol() + ' ' + str(self._value)
1339
+ if self._value else
1340
+ self.paint('Sin ícono', self.theme.muted))
1341
+ + (' /' + self.query if self.query else '')]
1342
+ if not self.focused:
1343
+ return out
1344
+ items = self.matches()
1345
+ first_row = max(0, self.index // self.columns - self.rows + 1)
1346
+ for r in range(first_row, first_row + self.rows):
1347
+ cells = []
1348
+ for c in range(self.columns):
1349
+ i = r * self.columns + c
1350
+ if i >= len(items):
1351
+ break
1352
+ sym = _fx.fit_line(items[i][1], 2)
1353
+ cell = ' ' + sym + ' '
1354
+ if i == self.index:
1355
+ cell = self.paint(cell, self.theme.selection) if \
1356
+ self._active() else '[' + sym + ']'
1357
+ cells.append(cell)
1358
+ if cells:
1359
+ out.append(' ' + ''.join(cells))
1360
+ if items:
1361
+ out.append(' ' + self.paint(items[min(self.index, len(items) - 1)]
1362
+ [0], self.theme.muted))
1363
+ return out
1364
+
1365
+
1366
+ class CoordinatesInput(Input):
1367
+ """Latitude and longitude, side by side. Value: ``{'latitude': ...,
1368
+ 'longitude': ...}``."""
1369
+
1370
+ focusable = False
1371
+
1372
+ def __init__(self, label=None, value=None, decimals=6, **kwargs):
1373
+ theme = kwargs.get('theme')
1374
+ colors = kwargs.get('use_colors')
1375
+ self.latitude = NumberInput('Latitud', minimum=-90, maximum=90,
1376
+ decimals=decimals, theme=theme,
1377
+ use_colors=colors)
1378
+ self.longitude = NumberInput('Longitud', minimum=-180, maximum=180,
1379
+ decimals=decimals, theme=theme,
1380
+ use_colors=colors)
1381
+ super(CoordinatesInput, self).__init__(label=label, value=value,
1382
+ **kwargs)
1383
+
1384
+ def children(self):
1385
+ return [self.latitude, self.longitude]
1386
+
1387
+ @property
1388
+ def value(self):
1389
+ lat, lon = self.latitude.value, self.longitude.value
1390
+ if lat is None and lon is None:
1391
+ return None
1392
+ return {'latitude': lat, 'longitude': lon}
1393
+
1394
+ @value.setter
1395
+ def value(self, new):
1396
+ if not new:
1397
+ return
1398
+ if isinstance(new, dict):
1399
+ lat, lon = new.get('latitude'), new.get('longitude')
1400
+ else:
1401
+ lat, lon = new
1402
+ self.latitude.value = lat
1403
+ self.longitude.value = lon
1404
+
1405
+ def is_empty(self):
1406
+ return self.value is None
1407
+
1408
+ def validate(self):
1409
+ errors = [self.latitude.validate(), self.longitude.validate()]
1410
+ self.error = next((e for e in errors if e), None)
1411
+ if self.error is None:
1412
+ return super(CoordinatesInput, self).validate()
1413
+ return self.error
1414
+
1415
+ def control(self, width, height):
1416
+ half = max(1, (width - 1) // 2)
1417
+ left = self.latitude.render(width=half).split('\n')
1418
+ right = self.longitude.render(width=width - half - 1).split('\n')
1419
+ from .base import join_columns
1420
+ return join_columns([left, right], [half, width - half - 1])
1421
+
1422
+
1423
+ class FileInput(Input):
1424
+ """
1425
+ A path, typed or picked from a browser (Enter opens it): Up/Down move,
1426
+ Enter opens a folder or picks a file, Backspace goes up, Escape closes.
1427
+ ``extensions`` limits the files listed; ``multiple=True`` picks several
1428
+ with Space. ``value`` is the path (a list with ``multiple``).
1429
+ """
1430
+
1431
+ def __init__(self, label=None, value=None, directory=None,
1432
+ extensions=None, multiple=False, show_hidden=False,
1433
+ max_visible=8, **kwargs):
1434
+ self.directory = os.path.abspath(directory or os.getcwd())
1435
+ self.extensions = tuple(e.lower() for e in (extensions or ()))
1436
+ self.multiple = bool(multiple)
1437
+ self.show_hidden = bool(show_hidden)
1438
+ self.max_visible = max(1, int(max_visible))
1439
+ self.open = False
1440
+ self.index = 0
1441
+ self.top = 0
1442
+ super(FileInput, self).__init__(label=label, value=value, **kwargs)
1443
+
1444
+ def coerce(self, value):
1445
+ if self.multiple:
1446
+ return list(value or [])
1447
+ return value
1448
+
1449
+ def entries(self):
1450
+ try:
1451
+ names = sorted(os.listdir(self.directory), key=str.lower)
1452
+ except OSError:
1453
+ return [('..', True)]
1454
+ out = [('..', True)]
1455
+ for name in names:
1456
+ if name.startswith('.') and not self.show_hidden:
1457
+ continue
1458
+ path = os.path.join(self.directory, name)
1459
+ is_dir = os.path.isdir(path)
1460
+ if not is_dir and self.extensions and \
1461
+ not name.lower().endswith(self.extensions):
1462
+ continue
1463
+ out.append((name, is_dir))
1464
+ out.sort(key=lambda e: (e[0] != '..', not e[1], e[0].lower()))
1465
+ return out
1466
+
1467
+ def on_blur(self):
1468
+ self.open = False
1469
+
1470
+ def _pick(self, path):
1471
+ if self.multiple:
1472
+ chosen = list(self._value)
1473
+ if path in chosen:
1474
+ chosen.remove(path)
1475
+ else:
1476
+ chosen.append(path)
1477
+ self.set_value(chosen)
1478
+ else:
1479
+ self.set_value(path)
1480
+ self.open = False
1481
+
1482
+ def on_key(self, key):
1483
+ k = str(key)
1484
+ if not self.open:
1485
+ if k in ('enter', 'space'):
1486
+ self.open = True
1487
+ self.index = self.top = 0
1488
+ self.refresh()
1489
+ return True
1490
+ return False
1491
+ items = self.entries()
1492
+ if k == 'escape':
1493
+ self.open = False
1494
+ elif k == 'up':
1495
+ self.index = max(0, self.index - 1)
1496
+ elif k == 'down':
1497
+ self.index = min(len(items) - 1, self.index + 1)
1498
+ elif k == 'backspace':
1499
+ self.directory = os.path.dirname(self.directory)
1500
+ self.index = self.top = 0
1501
+ elif k in ('enter', 'space'):
1502
+ name, is_dir = items[min(self.index, len(items) - 1)]
1503
+ path = os.path.normpath(os.path.join(self.directory, name))
1504
+ if is_dir and k == 'enter':
1505
+ self.directory = path
1506
+ self.index = self.top = 0
1507
+ elif not is_dir:
1508
+ self._pick(path)
1509
+ else:
1510
+ return k not in ('tab', 'shift+tab')
1511
+ self.refresh()
1512
+ return True
1513
+
1514
+ def control(self, width, height):
1515
+ if self.multiple:
1516
+ shown = ', '.join(os.path.basename(p) for p in self._value)
1517
+ else:
1518
+ shown = self._value or ''
1519
+ body = shown or self.paint('Elige un archivo…', self.theme.muted)
1520
+ out = [self.marker() + ' ' + _fx.truncate(body, max(1, width - 4),
1521
+ '…') ]
1522
+ if not self.open:
1523
+ return out
1524
+ out.append(' ' + self.paint(_fx.truncate(self.directory,
1525
+ max(1, width - 2), '…'),
1526
+ self.theme.muted))
1527
+ items = self.entries()
1528
+ if self.index < self.top:
1529
+ self.top = self.index
1530
+ if self.index >= self.top + self.max_visible:
1531
+ self.top = self.index - self.max_visible + 1
1532
+ chosen = self._value if self.multiple else [self._value]
1533
+ for i in range(self.top, min(len(items), self.top + self.max_visible)):
1534
+ name, is_dir = items[i]
1535
+ path = os.path.normpath(os.path.join(self.directory, name))
1536
+ mark = '▸ ' if is_dir else ('✔ ' if path in chosen else ' ')
1537
+ text = _fx.fit_line(mark + name + ('/' if is_dir and name != '..'
1538
+ else ''), max(1, width - 2))
1539
+ if i == self.index:
1540
+ text = self.paint(text, self.theme.selection) if \
1541
+ self._active() else '>' + text[1:]
1542
+ out.append(' ' + text)
1543
+ return out