flexablelib-components 3.3.0__py3-none-any.whl

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