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,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
|