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,828 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dates and times: a month calendar, date / time / date-time inputs, week,
|
|
3
|
+
month, year and weekday pickers, date ranges with presets, and a timer.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import calendar as _calendar
|
|
7
|
+
import datetime as _dt
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from flexablelib import _frames as _fx
|
|
11
|
+
|
|
12
|
+
from .base import Input, _Themed, join_columns
|
|
13
|
+
|
|
14
|
+
__all__ = ['Calendar', 'DateInput', 'TimeInput', 'DateTimeInput',
|
|
15
|
+
'WeekInput', 'MonthInput', 'YearInput', 'WeekdayInput',
|
|
16
|
+
'DateRangeInput', 'Timer', 'LOCALES']
|
|
17
|
+
|
|
18
|
+
LOCALES = {
|
|
19
|
+
'es': {
|
|
20
|
+
'months': ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
21
|
+
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre',
|
|
22
|
+
'Diciembre'],
|
|
23
|
+
'days': ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sá', 'Do'],
|
|
24
|
+
'weekdays': ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes',
|
|
25
|
+
'Sábado', 'Domingo'],
|
|
26
|
+
'today': 'Hoy', 'week': 'Sem',
|
|
27
|
+
},
|
|
28
|
+
'en': {
|
|
29
|
+
'months': ['January', 'February', 'March', 'April', 'May', 'June',
|
|
30
|
+
'July', 'August', 'September', 'October', 'November',
|
|
31
|
+
'December'],
|
|
32
|
+
'days': ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
|
|
33
|
+
'weekdays': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
|
|
34
|
+
'Saturday', 'Sunday'],
|
|
35
|
+
'today': 'Today', 'week': 'Wk',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_months(day, months):
|
|
41
|
+
month = day.month - 1 + months
|
|
42
|
+
year = day.year + month // 12
|
|
43
|
+
month = month % 12 + 1
|
|
44
|
+
last = _calendar.monthrange(year, month)[1]
|
|
45
|
+
return day.replace(year=year, month=month, day=min(day.day, last))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _as_date(value):
|
|
49
|
+
if value is None or value == '':
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(value, _dt.datetime):
|
|
52
|
+
return value.date()
|
|
53
|
+
if isinstance(value, _dt.date):
|
|
54
|
+
return value
|
|
55
|
+
return _dt.date.fromisoformat(str(value)[:10])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Calendar(_Themed):
|
|
59
|
+
"""
|
|
60
|
+
A month grid. Arrows move by day and week, PgUp/PgDn by month,
|
|
61
|
+
Ctrl-PgUp/PgDn by year, ``t`` jumps to today, Enter calls
|
|
62
|
+
``on_select(date)``. ``highlight(date) -> spec`` colours days; ``range``
|
|
63
|
+
``(start, end)`` shades a span; ``mode='week'`` selects whole weeks.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
focusable = True
|
|
67
|
+
|
|
68
|
+
def __init__(self, value=None, on_select=None, locale='es',
|
|
69
|
+
minimum=None, maximum=None, mode='day', today=None,
|
|
70
|
+
**kwargs):
|
|
71
|
+
super(Calendar, self).__init__(**kwargs)
|
|
72
|
+
self.today = today or _dt.date.today()
|
|
73
|
+
self.cursor = _as_date(value) or self.today
|
|
74
|
+
self.selected = _as_date(value)
|
|
75
|
+
self.on_select = on_select
|
|
76
|
+
self.names = LOCALES.get(locale, LOCALES['es'])
|
|
77
|
+
self.minimum = _as_date(minimum)
|
|
78
|
+
self.maximum = _as_date(maximum)
|
|
79
|
+
self.mode = mode
|
|
80
|
+
self.range = None
|
|
81
|
+
self.highlight = None
|
|
82
|
+
|
|
83
|
+
def natural_width(self):
|
|
84
|
+
return 22 + (4 if self.mode == 'week' else 0)
|
|
85
|
+
|
|
86
|
+
def _clamp(self, day):
|
|
87
|
+
if self.minimum and day < self.minimum:
|
|
88
|
+
return self.minimum
|
|
89
|
+
if self.maximum and day > self.maximum:
|
|
90
|
+
return self.maximum
|
|
91
|
+
return day
|
|
92
|
+
|
|
93
|
+
def move(self, days=0, months=0):
|
|
94
|
+
day = self.cursor + _dt.timedelta(days=days)
|
|
95
|
+
if months:
|
|
96
|
+
day = _add_months(day, months)
|
|
97
|
+
self.cursor = self._clamp(day)
|
|
98
|
+
self.refresh()
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def on_key(self, key):
|
|
102
|
+
k = str(key)
|
|
103
|
+
steps = {'left': -1, 'right': 1, 'up': -7, 'down': 7}
|
|
104
|
+
if k in steps:
|
|
105
|
+
return self.move(days=steps[k])
|
|
106
|
+
months = {'pageup': -1, 'pagedown': 1, 'ctrl+pageup': -12,
|
|
107
|
+
'ctrl+pagedown': 12}
|
|
108
|
+
if k in months:
|
|
109
|
+
return self.move(months=months[k])
|
|
110
|
+
if k == 'home':
|
|
111
|
+
return self.move(days=1 - self.cursor.day)
|
|
112
|
+
if k == 'end':
|
|
113
|
+
last = _calendar.monthrange(self.cursor.year,
|
|
114
|
+
self.cursor.month)[1]
|
|
115
|
+
return self.move(days=last - self.cursor.day)
|
|
116
|
+
if k == 't':
|
|
117
|
+
self.cursor = self._clamp(self.today)
|
|
118
|
+
self.refresh()
|
|
119
|
+
return True
|
|
120
|
+
if k in ('enter', 'space'):
|
|
121
|
+
day = self.cursor
|
|
122
|
+
if self.mode == 'week':
|
|
123
|
+
day = day - _dt.timedelta(days=day.weekday())
|
|
124
|
+
self.selected = day
|
|
125
|
+
if self.on_select is not None:
|
|
126
|
+
self.on_select(day)
|
|
127
|
+
self.refresh()
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def _in_range(self, day):
|
|
132
|
+
if self.range and self.range[0]:
|
|
133
|
+
start, end = self.range
|
|
134
|
+
end = end or start
|
|
135
|
+
if start > end:
|
|
136
|
+
start, end = end, start
|
|
137
|
+
return start <= day <= end
|
|
138
|
+
if self.mode == 'week' and self.selected:
|
|
139
|
+
return self.selected <= day < self.selected + \
|
|
140
|
+
_dt.timedelta(days=7)
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def draw(self, width, height):
|
|
144
|
+
year, month = self.cursor.year, self.cursor.month
|
|
145
|
+
title = '{0} {1}'.format(self.names['months'][month - 1], year)
|
|
146
|
+
week_col = self.mode == 'week'
|
|
147
|
+
grid_w = 20 + (4 if week_col else 0)
|
|
148
|
+
out = [_fx.fit_line(self.paint(title, self.theme.title),
|
|
149
|
+
grid_w, 'c')]
|
|
150
|
+
head = ' '.join(self.names['days'])
|
|
151
|
+
if week_col:
|
|
152
|
+
head = _fx.fit_line(self.names['week'], 3) + ' ' + head
|
|
153
|
+
out.append(self.paint(head, self.theme.muted))
|
|
154
|
+
cursor_week = self.cursor.isocalendar()[1]
|
|
155
|
+
for week in _calendar.Calendar(0).monthdatescalendar(year, month):
|
|
156
|
+
cells = []
|
|
157
|
+
if week_col:
|
|
158
|
+
num = week[0].isocalendar()[1]
|
|
159
|
+
cells.append(self.paint('{0:>3}'.format(num),
|
|
160
|
+
self.theme.accent if num ==
|
|
161
|
+
cursor_week and self.focused
|
|
162
|
+
else self.theme.muted))
|
|
163
|
+
row = ''
|
|
164
|
+
for k, day in enumerate(week):
|
|
165
|
+
text = '{0:>2}'.format(day.day)
|
|
166
|
+
sep = ' ' if k or week_col else ''
|
|
167
|
+
spec = None
|
|
168
|
+
if day.month != month:
|
|
169
|
+
spec = self.theme.muted
|
|
170
|
+
if self.highlight is not None:
|
|
171
|
+
spec = self.highlight(day) or spec
|
|
172
|
+
if self._in_range(day):
|
|
173
|
+
spec = 'reverse' if not self.focused else \
|
|
174
|
+
self.theme.selection
|
|
175
|
+
if day == self.selected and self.mode == 'day':
|
|
176
|
+
spec = 'bold ' + (self.theme.accent or '') + ' reverse'
|
|
177
|
+
if day == self.today:
|
|
178
|
+
spec = (spec or '') + ' underline'
|
|
179
|
+
if day == self.cursor and self.focused:
|
|
180
|
+
if self._active():
|
|
181
|
+
spec = self.theme.selection
|
|
182
|
+
elif k or week_col:
|
|
183
|
+
sep = '>'
|
|
184
|
+
if (self.minimum and day < self.minimum) or \
|
|
185
|
+
(self.maximum and day > self.maximum):
|
|
186
|
+
spec = 'dim'
|
|
187
|
+
row += sep + self.paint(text, spec.strip() if spec else None)
|
|
188
|
+
out.append(''.join(cells) + row)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _Picker(Input):
|
|
193
|
+
"""An input that opens an inline picker below its value."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, label=None, value=None, **kwargs):
|
|
196
|
+
self.open = False
|
|
197
|
+
super(_Picker, self).__init__(label=label, value=value, **kwargs)
|
|
198
|
+
|
|
199
|
+
def on_blur(self):
|
|
200
|
+
self.open = False
|
|
201
|
+
|
|
202
|
+
def shown(self): # pragma: no cover - abstract
|
|
203
|
+
return ''
|
|
204
|
+
|
|
205
|
+
def picker(self): # pragma: no cover - abstract
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def picker_key(self, key):
|
|
209
|
+
return self.picker().handle_key(key)
|
|
210
|
+
|
|
211
|
+
def on_key(self, key):
|
|
212
|
+
k = str(key)
|
|
213
|
+
if not self.open:
|
|
214
|
+
if k in ('enter', 'space', 'alt+down'):
|
|
215
|
+
self.open = True
|
|
216
|
+
self.sync()
|
|
217
|
+
self.refresh()
|
|
218
|
+
return True
|
|
219
|
+
if k == 'delete' and not self.required:
|
|
220
|
+
self.set_value(None)
|
|
221
|
+
return True
|
|
222
|
+
return self.closed_key(key)
|
|
223
|
+
if k == 'escape':
|
|
224
|
+
self.open = False
|
|
225
|
+
self.refresh()
|
|
226
|
+
return True
|
|
227
|
+
if k in ('tab', 'shift+tab'):
|
|
228
|
+
return False
|
|
229
|
+
self.picker_key(key)
|
|
230
|
+
self.refresh()
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
def closed_key(self, key):
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
def sync(self):
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
def chosen(self, value):
|
|
240
|
+
self.set_value(value)
|
|
241
|
+
self.open = False
|
|
242
|
+
|
|
243
|
+
def control(self, width, height):
|
|
244
|
+
shown = self.shown()
|
|
245
|
+
body = shown if shown else self.paint('—', self.theme.muted)
|
|
246
|
+
icon = self.paint('▾' if not self.open else '▴',
|
|
247
|
+
self.theme.accent if self.focused else
|
|
248
|
+
self.theme.muted)
|
|
249
|
+
out = [self.marker() + ' ' + _fx.fit_line(body, max(1, width - 4)) +
|
|
250
|
+
' ' + icon]
|
|
251
|
+
if self.open:
|
|
252
|
+
picker = self.picker()
|
|
253
|
+
picker.focused = True
|
|
254
|
+
picker.use_colors = self.use_colors
|
|
255
|
+
out.extend(' ' + line for line in
|
|
256
|
+
picker.render(width=max(1, width - 2)).split('\n'))
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DateInput(_Picker):
|
|
261
|
+
"""
|
|
262
|
+
A date. Enter opens a calendar; with it closed, ``+`` / ``-`` move a day
|
|
263
|
+
and ``t`` sets today. ``format`` is the ``strftime`` pattern shown.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(self, label=None, value=None, format='%Y-%m-%d',
|
|
267
|
+
locale='es', minimum=None, maximum=None, today=None,
|
|
268
|
+
**kwargs):
|
|
269
|
+
self.format = format
|
|
270
|
+
self.calendar = Calendar(value, locale=locale, minimum=minimum,
|
|
271
|
+
maximum=maximum, today=today,
|
|
272
|
+
theme=kwargs.get('theme'))
|
|
273
|
+
self.calendar.on_select = self.chosen
|
|
274
|
+
super(DateInput, self).__init__(label=label, value=value, **kwargs)
|
|
275
|
+
|
|
276
|
+
def coerce(self, value):
|
|
277
|
+
return _as_date(value)
|
|
278
|
+
|
|
279
|
+
def shown(self):
|
|
280
|
+
return self._value.strftime(self.format) if self._value else ''
|
|
281
|
+
|
|
282
|
+
def picker(self):
|
|
283
|
+
return self.calendar
|
|
284
|
+
|
|
285
|
+
def sync(self):
|
|
286
|
+
if self._value:
|
|
287
|
+
self.calendar.cursor = self._value
|
|
288
|
+
self.calendar.selected = self._value
|
|
289
|
+
|
|
290
|
+
def closed_key(self, key):
|
|
291
|
+
k = str(key)
|
|
292
|
+
if k in ('+', '-'):
|
|
293
|
+
base = self._value or self.calendar.today
|
|
294
|
+
self.set_value(base + _dt.timedelta(days=1 if k == '+' else -1))
|
|
295
|
+
return True
|
|
296
|
+
if k == 't':
|
|
297
|
+
self.set_value(self.calendar.today)
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class TimeInput(Input):
|
|
303
|
+
"""
|
|
304
|
+
``HH:MM`` (``seconds=True``: ``HH:MM:SS``). Left/Right pick the part,
|
|
305
|
+
Up/Down change it (by ``step`` minutes for minutes), digits type it.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, label=None, value=None, seconds=False, step=1,
|
|
309
|
+
**kwargs):
|
|
310
|
+
self.seconds = bool(seconds)
|
|
311
|
+
self.step = max(1, int(step))
|
|
312
|
+
self.part = 0
|
|
313
|
+
self._typed = ''
|
|
314
|
+
super(TimeInput, self).__init__(label=label, value=value, **kwargs)
|
|
315
|
+
|
|
316
|
+
def coerce(self, value):
|
|
317
|
+
if value is None or value == '':
|
|
318
|
+
return None
|
|
319
|
+
if isinstance(value, _dt.datetime):
|
|
320
|
+
return value.time().replace(microsecond=0)
|
|
321
|
+
if isinstance(value, _dt.time):
|
|
322
|
+
return value.replace(microsecond=0)
|
|
323
|
+
parts = [int(p) for p in str(value).split(':')]
|
|
324
|
+
while len(parts) < 3:
|
|
325
|
+
parts.append(0)
|
|
326
|
+
return _dt.time(*parts[:3])
|
|
327
|
+
|
|
328
|
+
def _parts(self):
|
|
329
|
+
return 3 if self.seconds else 2
|
|
330
|
+
|
|
331
|
+
def _change(self, delta, part=None):
|
|
332
|
+
t = self._value or _dt.time(0, 0)
|
|
333
|
+
part = self.part if part is None else part
|
|
334
|
+
h, m, s = t.hour, t.minute, t.second
|
|
335
|
+
if part == 0:
|
|
336
|
+
h = (h + delta) % 24
|
|
337
|
+
elif part == 1:
|
|
338
|
+
m = (m + delta * self.step) % 60
|
|
339
|
+
else:
|
|
340
|
+
s = (s + delta) % 60
|
|
341
|
+
self.set_value(_dt.time(h, m, s))
|
|
342
|
+
|
|
343
|
+
def on_key(self, key):
|
|
344
|
+
k = str(key)
|
|
345
|
+
if k in ('left', 'right'):
|
|
346
|
+
self.part = (self.part + (1 if k == 'right' else -1)) % \
|
|
347
|
+
self._parts()
|
|
348
|
+
self._typed = ''
|
|
349
|
+
elif k in ('up', 'down'):
|
|
350
|
+
self._change(1 if k == 'up' else -1)
|
|
351
|
+
elif len(k) == 1 and k.isdigit():
|
|
352
|
+
self._typed = (self._typed + k)[-2:]
|
|
353
|
+
limit = 24 if self.part == 0 else 60
|
|
354
|
+
number = int(self._typed)
|
|
355
|
+
if number >= limit:
|
|
356
|
+
number = int(k)
|
|
357
|
+
self._typed = k
|
|
358
|
+
t = self._value or _dt.time(0, 0)
|
|
359
|
+
values = [t.hour, t.minute, t.second]
|
|
360
|
+
values[self.part] = number
|
|
361
|
+
self.set_value(_dt.time(*values))
|
|
362
|
+
if len(self._typed) == 2 and self.part + 1 < self._parts():
|
|
363
|
+
self.part += 1
|
|
364
|
+
self._typed = ''
|
|
365
|
+
elif k == 'delete' and not self.required:
|
|
366
|
+
self.set_value(None)
|
|
367
|
+
elif k == 'enter':
|
|
368
|
+
return self.submit()
|
|
369
|
+
else:
|
|
370
|
+
return False
|
|
371
|
+
self.refresh()
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
def control(self, width, height):
|
|
375
|
+
t = self._value
|
|
376
|
+
values = ['--', '--', '--'] if t is None else \
|
|
377
|
+
['{0:02d}'.format(t.hour), '{0:02d}'.format(t.minute),
|
|
378
|
+
'{0:02d}'.format(t.second)]
|
|
379
|
+
parts = []
|
|
380
|
+
for i in range(self._parts()):
|
|
381
|
+
text = values[i]
|
|
382
|
+
if self.focused and i == self.part:
|
|
383
|
+
text = self.paint(text, self.theme.selection) if \
|
|
384
|
+
self._active() else '[' + text + ']'
|
|
385
|
+
parts.append(text)
|
|
386
|
+
return [self.marker() + ' ' + ':'.join(parts)]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class DateTimeInput(Input):
|
|
390
|
+
"""A date and a time side by side; the value is a ``datetime``."""
|
|
391
|
+
|
|
392
|
+
focusable = False
|
|
393
|
+
|
|
394
|
+
def __init__(self, label=None, value=None, seconds=False, locale='es',
|
|
395
|
+
today=None, **kwargs):
|
|
396
|
+
common = {'theme': kwargs.get('theme'),
|
|
397
|
+
'use_colors': kwargs.get('use_colors')}
|
|
398
|
+
self.date = DateInput(locale=locale, today=today, **common)
|
|
399
|
+
self.time = TimeInput(seconds=seconds, **common)
|
|
400
|
+
super(DateTimeInput, self).__init__(label=label, value=value,
|
|
401
|
+
**kwargs)
|
|
402
|
+
|
|
403
|
+
def children(self):
|
|
404
|
+
return [self.date, self.time]
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def value(self):
|
|
408
|
+
if self.date.value is None:
|
|
409
|
+
return None
|
|
410
|
+
return _dt.datetime.combine(self.date.value,
|
|
411
|
+
self.time.value or _dt.time(0, 0))
|
|
412
|
+
|
|
413
|
+
@value.setter
|
|
414
|
+
def value(self, new):
|
|
415
|
+
if new is None:
|
|
416
|
+
return
|
|
417
|
+
if isinstance(new, str):
|
|
418
|
+
new = _dt.datetime.fromisoformat(new)
|
|
419
|
+
self.date.value = new
|
|
420
|
+
self.time.value = new if isinstance(new, _dt.datetime) else None
|
|
421
|
+
|
|
422
|
+
def is_empty(self):
|
|
423
|
+
return self.date.value is None
|
|
424
|
+
|
|
425
|
+
def control(self, width, height):
|
|
426
|
+
left_w = max(14, width // 2)
|
|
427
|
+
left = self.date.render(width=left_w).split('\n')
|
|
428
|
+
right = self.time.render(width=max(1, width - left_w - 1)).split('\n')
|
|
429
|
+
return join_columns([left, right], [left_w, max(1, width - left_w -
|
|
430
|
+
1)])
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class WeekInput(_Picker):
|
|
434
|
+
"""A week; the value is its Monday. Shown as ``2026-W46 · 09/11–15/11``."""
|
|
435
|
+
|
|
436
|
+
def __init__(self, label=None, value=None, locale='es', today=None,
|
|
437
|
+
**kwargs):
|
|
438
|
+
self.calendar = Calendar(value, locale=locale, mode='week',
|
|
439
|
+
today=today, theme=kwargs.get('theme'))
|
|
440
|
+
self.calendar.on_select = self.chosen
|
|
441
|
+
super(WeekInput, self).__init__(label=label, value=value, **kwargs)
|
|
442
|
+
|
|
443
|
+
def coerce(self, value):
|
|
444
|
+
day = _as_date(value)
|
|
445
|
+
return None if day is None else day - _dt.timedelta(
|
|
446
|
+
days=day.weekday())
|
|
447
|
+
|
|
448
|
+
def shown(self):
|
|
449
|
+
if not self._value:
|
|
450
|
+
return ''
|
|
451
|
+
year, week, _ = self._value.isocalendar()
|
|
452
|
+
end = self._value + _dt.timedelta(days=6)
|
|
453
|
+
return '{0}-W{1:02d} · {2:%d/%m}–{3:%d/%m}'.format(year, week,
|
|
454
|
+
self._value, end)
|
|
455
|
+
|
|
456
|
+
def picker(self):
|
|
457
|
+
return self.calendar
|
|
458
|
+
|
|
459
|
+
def sync(self):
|
|
460
|
+
if self._value:
|
|
461
|
+
self.calendar.cursor = self._value
|
|
462
|
+
self.calendar.selected = self._value
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _GridPicker(_Themed):
|
|
466
|
+
"""A grid of labelled choices (months, years)."""
|
|
467
|
+
|
|
468
|
+
focusable = True
|
|
469
|
+
|
|
470
|
+
def __init__(self, labels, columns, index=0, title='', **kwargs):
|
|
471
|
+
super(_GridPicker, self).__init__(**kwargs)
|
|
472
|
+
self.labels = labels
|
|
473
|
+
self.columns = columns
|
|
474
|
+
self.index = index
|
|
475
|
+
self.title = title
|
|
476
|
+
self.on_select = None
|
|
477
|
+
self.on_page = None
|
|
478
|
+
|
|
479
|
+
def on_key(self, key):
|
|
480
|
+
k = str(key)
|
|
481
|
+
moves = {'left': -1, 'right': 1, 'up': -self.columns,
|
|
482
|
+
'down': self.columns}
|
|
483
|
+
if k in moves:
|
|
484
|
+
self.index = max(0, min(len(self.labels) - 1,
|
|
485
|
+
self.index + moves[k]))
|
|
486
|
+
return True
|
|
487
|
+
if k in ('pageup', 'pagedown') and self.on_page:
|
|
488
|
+
self.on_page(-1 if k == 'pageup' else 1)
|
|
489
|
+
return True
|
|
490
|
+
if k in ('enter', 'space') and self.on_select:
|
|
491
|
+
self.on_select(self.index)
|
|
492
|
+
return True
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
def draw(self, width, height):
|
|
496
|
+
cell = max(len(label) for label in self.labels) + 2
|
|
497
|
+
out = [self.paint(self.title, self.theme.title)] if self.title else []
|
|
498
|
+
for r in range(0, len(self.labels), self.columns):
|
|
499
|
+
cells = []
|
|
500
|
+
for i in range(r, min(len(self.labels), r + self.columns)):
|
|
501
|
+
text = _fx.fit_line(self.labels[i], cell, 'c')
|
|
502
|
+
if i == self.index and self.focused:
|
|
503
|
+
text = self.paint(text, self.theme.selection) if \
|
|
504
|
+
self._active() else '[' + text[1:-1] + ']'
|
|
505
|
+
cells.append(text)
|
|
506
|
+
out.append(''.join(cells))
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class MonthInput(_Picker):
|
|
511
|
+
"""A month (the value is its first day); PgUp/PgDn change the year."""
|
|
512
|
+
|
|
513
|
+
def __init__(self, label=None, value=None, locale='es', today=None,
|
|
514
|
+
**kwargs):
|
|
515
|
+
self.names = LOCALES.get(locale, LOCALES['es'])
|
|
516
|
+
self.today = today or _dt.date.today()
|
|
517
|
+
self.year = (_as_date(value) or self.today).year
|
|
518
|
+
self.grid = _GridPicker([m[:3] for m in self.names['months']], 4,
|
|
519
|
+
theme=kwargs.get('theme'))
|
|
520
|
+
self.grid.on_select = lambda i: self.chosen(_dt.date(self.year,
|
|
521
|
+
i + 1, 1))
|
|
522
|
+
self.grid.on_page = self._page
|
|
523
|
+
super(MonthInput, self).__init__(label=label, value=value, **kwargs)
|
|
524
|
+
|
|
525
|
+
def _page(self, step):
|
|
526
|
+
self.year += step
|
|
527
|
+
self.grid.title = str(self.year)
|
|
528
|
+
|
|
529
|
+
def coerce(self, value):
|
|
530
|
+
day = _as_date(value)
|
|
531
|
+
return None if day is None else day.replace(day=1)
|
|
532
|
+
|
|
533
|
+
def shown(self):
|
|
534
|
+
if not self._value:
|
|
535
|
+
return ''
|
|
536
|
+
return '{0} {1}'.format(self.names['months'][self._value.month - 1],
|
|
537
|
+
self._value.year)
|
|
538
|
+
|
|
539
|
+
def sync(self):
|
|
540
|
+
base = self._value or self.today
|
|
541
|
+
self.year = base.year
|
|
542
|
+
self.grid.index = base.month - 1
|
|
543
|
+
self.grid.title = str(self.year)
|
|
544
|
+
|
|
545
|
+
def picker(self):
|
|
546
|
+
return self.grid
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class YearInput(_Picker):
|
|
550
|
+
"""A year (the value is January 1st, or an ``int`` with
|
|
551
|
+
``as_int=True``); the picker shows twelve years, PgUp/PgDn page."""
|
|
552
|
+
|
|
553
|
+
def __init__(self, label=None, value=None, as_int=False, today=None,
|
|
554
|
+
**kwargs):
|
|
555
|
+
self.as_int = bool(as_int)
|
|
556
|
+
self.today = today or _dt.date.today()
|
|
557
|
+
self.first = self.today.year - 5
|
|
558
|
+
self.grid = _GridPicker([], 4, theme=kwargs.get('theme'))
|
|
559
|
+
self.grid.on_select = lambda i: self.chosen(self.first + i)
|
|
560
|
+
self.grid.on_page = self._page
|
|
561
|
+
super(YearInput, self).__init__(label=label, value=value, **kwargs)
|
|
562
|
+
self._labels()
|
|
563
|
+
|
|
564
|
+
def _labels(self):
|
|
565
|
+
self.grid.labels = [str(self.first + i) for i in range(12)]
|
|
566
|
+
|
|
567
|
+
def _page(self, step):
|
|
568
|
+
self.first += 12 * step
|
|
569
|
+
self._labels()
|
|
570
|
+
|
|
571
|
+
def coerce(self, value):
|
|
572
|
+
if value is None or value == '':
|
|
573
|
+
return None
|
|
574
|
+
year = value if isinstance(value, int) else _as_date(value).year
|
|
575
|
+
return year if self.as_int else _dt.date(year, 1, 1)
|
|
576
|
+
|
|
577
|
+
def _year(self):
|
|
578
|
+
if self._value is None:
|
|
579
|
+
return None
|
|
580
|
+
return self._value if self.as_int else self._value.year
|
|
581
|
+
|
|
582
|
+
def shown(self):
|
|
583
|
+
year = self._year()
|
|
584
|
+
return '' if year is None else str(year)
|
|
585
|
+
|
|
586
|
+
def sync(self):
|
|
587
|
+
year = self._year() or self.today.year
|
|
588
|
+
self.first = year - 5
|
|
589
|
+
self._labels()
|
|
590
|
+
self.grid.index = 5
|
|
591
|
+
|
|
592
|
+
def picker(self):
|
|
593
|
+
return self.grid
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class WeekdayInput(Input):
|
|
597
|
+
"""A day of the week (0 = Monday ... 6 = Sunday) as a row of buttons."""
|
|
598
|
+
|
|
599
|
+
def __init__(self, label=None, value=None, locale='es', short=True,
|
|
600
|
+
**kwargs):
|
|
601
|
+
names = LOCALES.get(locale, LOCALES['es'])
|
|
602
|
+
self.names = names['days'] if short else names['weekdays']
|
|
603
|
+
super(WeekdayInput, self).__init__(label=label, value=value, **kwargs)
|
|
604
|
+
|
|
605
|
+
def coerce(self, value):
|
|
606
|
+
return None if value is None else int(value) % 7
|
|
607
|
+
|
|
608
|
+
def on_key(self, key):
|
|
609
|
+
k = str(key)
|
|
610
|
+
if k in ('left', 'right'):
|
|
611
|
+
current = self._value if self._value is not None else -1
|
|
612
|
+
self.set_value((current + (1 if k == 'right' else -1)) % 7)
|
|
613
|
+
return True
|
|
614
|
+
if len(k) == 1 and k in '1234567':
|
|
615
|
+
self.set_value(int(k) - 1)
|
|
616
|
+
return True
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
def control(self, width, height):
|
|
620
|
+
parts = []
|
|
621
|
+
for i, name in enumerate(self.names):
|
|
622
|
+
text = ' {0} '.format(name)
|
|
623
|
+
if i == self._value:
|
|
624
|
+
text = self.paint(text, self.theme.selection if self.focused
|
|
625
|
+
else 'reverse') if self._active() else \
|
|
626
|
+
'[' + name + ']'
|
|
627
|
+
parts.append(text)
|
|
628
|
+
return [self.marker() + ' ' + ''.join(parts)]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class DateRangeInput(_Picker):
|
|
632
|
+
"""
|
|
633
|
+
A span of dates: in the picker, Enter marks the start then the end, or
|
|
634
|
+
pick a preset on the left (Up/Down there, Right to the calendar). The
|
|
635
|
+
value is ``(start, end)``.
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
PRESETS = ['Hoy', 'Ayer', 'Últimos 7 días', 'Últimos 30 días',
|
|
639
|
+
'Este mes', 'Mes pasado', 'Este año']
|
|
640
|
+
|
|
641
|
+
def __init__(self, label=None, value=None, locale='es', today=None,
|
|
642
|
+
**kwargs):
|
|
643
|
+
self.today = today or _dt.date.today()
|
|
644
|
+
self.calendar = Calendar(locale=locale, today=self.today,
|
|
645
|
+
theme=kwargs.get('theme'))
|
|
646
|
+
self.calendar.on_select = self._mark
|
|
647
|
+
self.calendar.range = (None, None)
|
|
648
|
+
self.preset = 0
|
|
649
|
+
self.in_presets = True
|
|
650
|
+
self._start = None
|
|
651
|
+
super(DateRangeInput, self).__init__(label=label, value=value,
|
|
652
|
+
**kwargs)
|
|
653
|
+
|
|
654
|
+
def coerce(self, value):
|
|
655
|
+
if not value:
|
|
656
|
+
return None
|
|
657
|
+
start, end = (_as_date(v) for v in value)
|
|
658
|
+
if start and end and start > end:
|
|
659
|
+
start, end = end, start
|
|
660
|
+
return (start, end)
|
|
661
|
+
|
|
662
|
+
def preset_range(self, index):
|
|
663
|
+
t = self.today
|
|
664
|
+
if index == 0:
|
|
665
|
+
return (t, t)
|
|
666
|
+
if index == 1:
|
|
667
|
+
y = t - _dt.timedelta(days=1)
|
|
668
|
+
return (y, y)
|
|
669
|
+
if index == 2:
|
|
670
|
+
return (t - _dt.timedelta(days=6), t)
|
|
671
|
+
if index == 3:
|
|
672
|
+
return (t - _dt.timedelta(days=29), t)
|
|
673
|
+
if index == 4:
|
|
674
|
+
return (t.replace(day=1), t)
|
|
675
|
+
if index == 5:
|
|
676
|
+
last = t.replace(day=1) - _dt.timedelta(days=1)
|
|
677
|
+
return (last.replace(day=1), last)
|
|
678
|
+
return (t.replace(month=1, day=1), t)
|
|
679
|
+
|
|
680
|
+
def _mark(self, day):
|
|
681
|
+
if self._start is None:
|
|
682
|
+
self._start = day
|
|
683
|
+
self.calendar.range = (day, None)
|
|
684
|
+
else:
|
|
685
|
+
self.chosen((self._start, day))
|
|
686
|
+
self._start = None
|
|
687
|
+
|
|
688
|
+
def sync(self):
|
|
689
|
+
self._start = None
|
|
690
|
+
self.calendar.range = self._value or (None, None)
|
|
691
|
+
if self._value:
|
|
692
|
+
self.calendar.cursor = self._value[0]
|
|
693
|
+
|
|
694
|
+
def picker(self):
|
|
695
|
+
return self.calendar
|
|
696
|
+
|
|
697
|
+
def picker_key(self, key):
|
|
698
|
+
k = str(key)
|
|
699
|
+
if self.in_presets:
|
|
700
|
+
if k in ('up', 'down'):
|
|
701
|
+
self.preset = (self.preset + (1 if k == 'down' else -1)) % \
|
|
702
|
+
len(self.PRESETS)
|
|
703
|
+
elif k == 'enter':
|
|
704
|
+
self.chosen(self.preset_range(self.preset))
|
|
705
|
+
elif k == 'right':
|
|
706
|
+
self.in_presets = False
|
|
707
|
+
return True
|
|
708
|
+
if k == 'left' and self.calendar.cursor.weekday() == 0:
|
|
709
|
+
self.in_presets = True
|
|
710
|
+
return True
|
|
711
|
+
return self.calendar.handle_key(key)
|
|
712
|
+
|
|
713
|
+
def shown(self):
|
|
714
|
+
if not self._value:
|
|
715
|
+
return ''
|
|
716
|
+
start, end = self._value
|
|
717
|
+
return '{0:%Y-%m-%d} → {1:%Y-%m-%d}'.format(start, end)
|
|
718
|
+
|
|
719
|
+
def control(self, width, height):
|
|
720
|
+
out = super(DateRangeInput, self).control(width, height)
|
|
721
|
+
if not self.open:
|
|
722
|
+
return out
|
|
723
|
+
out = out[:1]
|
|
724
|
+
self.calendar.focused = not self.in_presets
|
|
725
|
+
cal = self.calendar.render(width=22).split('\n')
|
|
726
|
+
presets = []
|
|
727
|
+
for i, name in enumerate(self.PRESETS):
|
|
728
|
+
text = _fx.fit_line(' ' + name, 17)
|
|
729
|
+
if i == self.preset and self.in_presets:
|
|
730
|
+
text = self.paint(text, self.theme.selection) if \
|
|
731
|
+
self._active() else '>' + text[1:]
|
|
732
|
+
presets.append(text)
|
|
733
|
+
out.extend(' ' + line for line in
|
|
734
|
+
join_columns([presets, cal], [17, 22], gap=2))
|
|
735
|
+
if self._start is not None:
|
|
736
|
+
out.append(' ' + self.paint('Inicio: {0:%Y-%m-%d} · elige el fin'
|
|
737
|
+
.format(self._start),
|
|
738
|
+
self.theme.muted))
|
|
739
|
+
return out
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
class Timer(_Themed):
|
|
743
|
+
"""
|
|
744
|
+
Elapsed time since ``start`` (until ``end``, or now): ``01:23:45``.
|
|
745
|
+
``interactive=True`` makes it a stopwatch: Space starts and stops, ``r``
|
|
746
|
+
resets. ``clock`` returns seconds (``time.monotonic`` by default) and is
|
|
747
|
+
only used by the stopwatch; ``start`` / ``end`` may be datetimes.
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
def __init__(self, start=None, end=None, interactive=False, label=None,
|
|
751
|
+
clock=time.monotonic, now=None, **kwargs):
|
|
752
|
+
super(Timer, self).__init__(**kwargs)
|
|
753
|
+
self.start = start
|
|
754
|
+
self.end = end
|
|
755
|
+
self.interactive = bool(interactive)
|
|
756
|
+
self.focusable = self.interactive
|
|
757
|
+
self.label = label
|
|
758
|
+
self.clock = clock
|
|
759
|
+
self.now = now or _dt.datetime.now
|
|
760
|
+
self._accum = 0.0
|
|
761
|
+
self._since = None
|
|
762
|
+
|
|
763
|
+
@property
|
|
764
|
+
def running(self):
|
|
765
|
+
if self.interactive:
|
|
766
|
+
return self._since is not None
|
|
767
|
+
return self.start is not None and self.end is None
|
|
768
|
+
|
|
769
|
+
def elapsed(self):
|
|
770
|
+
"""Seconds elapsed."""
|
|
771
|
+
if self.interactive:
|
|
772
|
+
extra = self.clock() - self._since if self._since is not None \
|
|
773
|
+
else 0.0
|
|
774
|
+
return self._accum + extra
|
|
775
|
+
if self.start is None:
|
|
776
|
+
return 0.0
|
|
777
|
+
end = self.end or self.now()
|
|
778
|
+
return max(0.0, (end - self.start).total_seconds())
|
|
779
|
+
|
|
780
|
+
def toggle(self):
|
|
781
|
+
if self._since is None:
|
|
782
|
+
self._since = self.clock()
|
|
783
|
+
else:
|
|
784
|
+
self._accum += self.clock() - self._since
|
|
785
|
+
self._since = None
|
|
786
|
+
self.refresh()
|
|
787
|
+
return True
|
|
788
|
+
|
|
789
|
+
def reset(self):
|
|
790
|
+
self._accum = 0.0
|
|
791
|
+
self._since = self.clock() if self._since is not None else None
|
|
792
|
+
self.refresh()
|
|
793
|
+
return True
|
|
794
|
+
|
|
795
|
+
def text(self):
|
|
796
|
+
total = int(self.elapsed())
|
|
797
|
+
hours, rest = divmod(total, 3600)
|
|
798
|
+
minutes, seconds = divmod(rest, 60)
|
|
799
|
+
if hours >= 24:
|
|
800
|
+
days, hours = divmod(hours, 24)
|
|
801
|
+
return '{0}d {1:02d}:{2:02d}:{3:02d}'.format(days, hours, minutes,
|
|
802
|
+
seconds)
|
|
803
|
+
return '{0:02d}:{1:02d}:{2:02d}'.format(hours, minutes, seconds)
|
|
804
|
+
|
|
805
|
+
def natural_width(self):
|
|
806
|
+
return len(self.text()) + 2 + len(self.label or '') + 1
|
|
807
|
+
|
|
808
|
+
def on_key(self, key):
|
|
809
|
+
if not self.interactive:
|
|
810
|
+
return False
|
|
811
|
+
k = str(key)
|
|
812
|
+
if k in ('space', 'enter'):
|
|
813
|
+
return self.toggle()
|
|
814
|
+
if k == 'r':
|
|
815
|
+
return self.reset()
|
|
816
|
+
return False
|
|
817
|
+
|
|
818
|
+
def draw(self, width, height):
|
|
819
|
+
icon = '⏱ ' if self._active() else ''
|
|
820
|
+
spec = self.theme.success if self.running else self.theme.muted
|
|
821
|
+
text = icon + self.paint(self.text(), 'bold ' + spec)
|
|
822
|
+
if self.label:
|
|
823
|
+
text = self.label + ' ' + text
|
|
824
|
+
if self.interactive and self.focused:
|
|
825
|
+
text += self.paint(' espacio: ' + ('pausa' if self.running
|
|
826
|
+
else 'iniciar') + ' · r',
|
|
827
|
+
self.theme.muted)
|
|
828
|
+
return [text]
|