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,1257 @@
1
+ """
2
+ Navigation and structure: tabs, menus (also as context menus and menu
3
+ buttons), pagination, sidebar, top bar, toolbar, wizard, accordion,
4
+ carousel, tree / object inspector, command palette, split panes, stacks and
5
+ the application shell that wires them together.
6
+ """
7
+
8
+ from flexablelib import _frames as _fx
9
+ from flexablelib.layout import _accepts_height, _natural_width, _render_child
10
+
11
+ from .base import Button, _Themed, frame, join_columns
12
+
13
+ __all__ = ['Tabs', 'Menu', 'ContextMenu', 'MenuButton', 'Pagination',
14
+ 'Sidebar', 'TopBar', 'Toolbar', 'Wizard', 'Accordion',
15
+ 'Carousel', 'Tree', 'CommandPalette', 'Split', 'Stack',
16
+ 'AppShell', 'SortControl', 'render_into']
17
+
18
+
19
+ def render_into(child, width, height=None):
20
+ """Lines of any renderable at ``width`` (and ``height`` when it takes
21
+ one)."""
22
+ if child is None:
23
+ return []
24
+ if isinstance(child, str):
25
+ lines = child.split('\n')
26
+ elif height is not None and _accepts_height(child):
27
+ lines = child.render(width=width, height=height).split('\n')
28
+ else:
29
+ text = _render_child(child, width)
30
+ lines = text.split('\n') if text else []
31
+ if height is not None:
32
+ lines = lines[:height]
33
+ return lines
34
+
35
+
36
+ class Tabs(_Themed):
37
+ """
38
+ Tabbed panes: ``tabs`` is a mapping (or pairs) of title to content.
39
+ Left/Right (or 1-9) switch while the tab bar has the focus; the content's
40
+ own components follow it in the focus order. ``on_change(index)``.
41
+ """
42
+
43
+ focusable = True
44
+ expand = True
45
+
46
+ def __init__(self, tabs, index=0, on_change=None, **kwargs):
47
+ super(Tabs, self).__init__(**kwargs)
48
+ self.tabs = list(tabs.items()) if isinstance(tabs, dict) \
49
+ else list(tabs)
50
+ self.index = index
51
+ self.on_change = on_change
52
+
53
+ @property
54
+ def current(self):
55
+ return self.tabs[self.index][1] if self.tabs else None
56
+
57
+ def children(self):
58
+ return [self.current] if self.current is not None else []
59
+
60
+ def natural_width(self):
61
+ bar = sum(_fx.visible_width(t) + 3 for t, _ in self.tabs)
62
+ return max([bar] + [_natural_width(c) for _, c in self.tabs])
63
+
64
+ def select(self, index):
65
+ index = max(0, min(len(self.tabs) - 1, index))
66
+ if index != self.index:
67
+ self.index = index
68
+ if self.on_change is not None:
69
+ self.on_change(index)
70
+ self.refresh()
71
+ return True
72
+
73
+ def on_key(self, key):
74
+ k = str(key)
75
+ if k in ('left', 'right'):
76
+ n = len(self.tabs)
77
+ return self.select((self.index + (1 if k == 'right' else -1)) % n)
78
+ if len(k) == 1 and k.isdigit() and 0 < int(k) <= len(self.tabs):
79
+ return self.select(int(k) - 1)
80
+ return False
81
+
82
+ def draw(self, width, height):
83
+ parts = []
84
+ for i, (title, _) in enumerate(self.tabs):
85
+ text = ' {0} '.format(title)
86
+ if i == self.index:
87
+ spec = self.theme.selection if self.focused else 'bold reverse'
88
+ text = self.paint(text, spec) if self._active() else \
89
+ '[' + title + ']'
90
+ else:
91
+ text = self.paint(text, self.theme.muted)
92
+ parts.append(text)
93
+ bar = self.paint('│', self.theme.border).join(parts) if \
94
+ self._active() else ' '.join(parts)
95
+ out = [bar, self.paint('─' * width, self.theme.accent if self.focused
96
+ else self.theme.border)]
97
+ room = None if height is None else max(0, height - 2)
98
+ out.extend(render_into(self.current, width, room))
99
+ return out
100
+
101
+
102
+ class Menu(_Themed):
103
+ """
104
+ A vertical menu. ``items``: labels, ``(label, action)``, or dicts with
105
+ ``label``, ``action``, ``shortcut``, ``icon``, ``disabled``; ``'-'`` is a
106
+ separator. Up/Down move (skipping separators and disabled items), Enter
107
+ runs the action, a letter jumps to the item starting with it, and an
108
+ item's ``shortcut`` runs it directly. ``on_select(index, item)``.
109
+ """
110
+
111
+ focusable = True
112
+
113
+ def __init__(self, items, on_select=None, index=0, max_visible=None,
114
+ **kwargs):
115
+ super(Menu, self).__init__(**kwargs)
116
+ self.items = [self._item(i) for i in items]
117
+ self.on_select = on_select
118
+ self.index = index
119
+ self.max_visible = max_visible
120
+ self.top = 0
121
+ if self.items and not self._enabled(self.index):
122
+ self._step(1)
123
+
124
+ @staticmethod
125
+ def _item(item):
126
+ if item == '-' or item is None:
127
+ return {'separator': True, 'label': ''}
128
+ if isinstance(item, dict):
129
+ return dict(item)
130
+ if isinstance(item, (tuple, list)):
131
+ return {'label': str(item[0]), 'action': item[1] if len(item) > 1
132
+ else None}
133
+ return {'label': str(item)}
134
+
135
+ def _enabled(self, i):
136
+ item = self.items[i]
137
+ return not item.get('separator') and not item.get('disabled')
138
+
139
+ def _step(self, step):
140
+ n = len(self.items)
141
+ for _ in range(n):
142
+ self.index = (self.index + step) % n
143
+ if self._enabled(self.index):
144
+ break
145
+
146
+ def natural_width(self):
147
+ return max([_fx.visible_width(self._text(i)) for i in self.items] or
148
+ [8]) + 2
149
+
150
+ def _text(self, item):
151
+ text = item['label']
152
+ if item.get('icon'):
153
+ text = item['icon'] + ' ' + text
154
+ return text
155
+
156
+ def activate(self, i=None):
157
+ i = self.index if i is None else i
158
+ if not self.items or not self._enabled(i):
159
+ return False
160
+ self.index = i
161
+ item = self.items[i]
162
+ if item.get('action') is not None:
163
+ item['action']()
164
+ if self.on_select is not None:
165
+ self.on_select(i, item)
166
+ self.refresh()
167
+ return True
168
+
169
+ def on_key(self, key):
170
+ k = str(key)
171
+ if not self.items:
172
+ return False
173
+ if k in ('up', 'k'):
174
+ self._step(-1)
175
+ elif k in ('down', 'j'):
176
+ self._step(1)
177
+ elif k == 'home':
178
+ self.index = len(self.items) - 1
179
+ self._step(1)
180
+ elif k == 'end':
181
+ self.index = 0
182
+ self._step(-1)
183
+ elif k in ('enter', 'space'):
184
+ return self.activate()
185
+ else:
186
+ for i, item in enumerate(self.items):
187
+ if item.get('shortcut') == k and self._enabled(i):
188
+ return self.activate(i)
189
+ if len(k) == 1:
190
+ order = list(range(self.index + 1, len(self.items))) + \
191
+ list(range(0, self.index + 1))
192
+ for i in order:
193
+ if self._enabled(i) and \
194
+ self.items[i]['label'][:1].lower() == k.lower():
195
+ self.index = i
196
+ self.refresh()
197
+ return True
198
+ return False
199
+ self.refresh()
200
+ return True
201
+
202
+ def draw(self, width, height):
203
+ rows = self.max_visible or height or len(self.items)
204
+ if self.index < self.top:
205
+ self.top = self.index
206
+ if self.index >= self.top + rows:
207
+ self.top = self.index - rows + 1
208
+ out = []
209
+ for i in range(self.top, min(len(self.items), self.top + rows)):
210
+ item = self.items[i]
211
+ if item.get('separator'):
212
+ out.append(self.paint('─' * width, self.theme.border))
213
+ continue
214
+ shortcut = item.get('shortcut') or ''
215
+ room = max(1, width - 2 - (len(shortcut) + 1 if shortcut else 0))
216
+ text = ' ' + _fx.fit_line(self._text(item), room) + \
217
+ (' ' + shortcut if shortcut else '') + ' '
218
+ if item.get('disabled'):
219
+ out.append(self.paint(text, 'dim'))
220
+ elif i == self.index and (self.focused or not self._active()):
221
+ out.append(self.paint(text, self.theme.selection) if
222
+ self._active() else '>' + text[1:])
223
+ elif i == self.index:
224
+ out.append(self.paint(text, 'bold'))
225
+ else:
226
+ if shortcut:
227
+ text = text[:-len(shortcut) - 1] + self.paint(
228
+ shortcut, self.theme.muted) + ' '
229
+ out.append(text)
230
+ return out
231
+
232
+
233
+ class ContextMenu(Menu):
234
+ """A :class:`Menu` in a framed popup: ``open(app, position)`` shows it,
235
+ Escape or choosing an item closes it."""
236
+
237
+ def natural_width(self):
238
+ return super(ContextMenu, self).natural_width() + 2
239
+
240
+ def open(self, app, position='center'):
241
+ app.push(self, position=position)
242
+ app.focus(self)
243
+ return self
244
+
245
+ def close(self):
246
+ if self.app is not None:
247
+ self.app.pop(self)
248
+
249
+ def activate(self, i=None):
250
+ done = super(ContextMenu, self).activate(i)
251
+ if done:
252
+ self.close()
253
+ return done
254
+
255
+ def on_key(self, key):
256
+ if str(key) == 'escape':
257
+ self.close()
258
+ return True
259
+ super(ContextMenu, self).on_key(key)
260
+ return True
261
+
262
+ def draw(self, width, height):
263
+ inner = super(ContextMenu, self).draw(max(1, width - 2), height)
264
+ return frame(inner, width, None, self.theme.accent, self.paint)
265
+
266
+
267
+ class MenuButton(Button):
268
+ """A button that opens a :class:`ContextMenu` of ``items`` below it."""
269
+
270
+ def __init__(self, label, items, **kwargs):
271
+ super(MenuButton, self).__init__(label + ' ▾', **kwargs)
272
+ self.menu = ContextMenu(items, theme=self.theme,
273
+ use_colors=kwargs.get('use_colors'))
274
+
275
+ def press(self):
276
+ self.presses += 1
277
+ if self.app is not None:
278
+ self.menu.open(self.app, position='center')
279
+ return True
280
+
281
+
282
+ class Pagination(_Themed):
283
+ """
284
+ ``‹ 1 … 4 [5] 6 … 20 ›`` with the item range: Left/Right (or PgUp/PgDn)
285
+ change page, Home/End jump. ``on_change(page)``; pages count from 1.
286
+ """
287
+
288
+ focusable = True
289
+
290
+ def __init__(self, total, page_size=20, page=1, on_change=None,
291
+ **kwargs):
292
+ super(Pagination, self).__init__(**kwargs)
293
+ self.total = max(0, int(total))
294
+ self.page_size = max(1, int(page_size))
295
+ self.page = page
296
+ self.on_change = on_change
297
+
298
+ @property
299
+ def pages(self):
300
+ return max(1, -(-self.total // self.page_size))
301
+
302
+ def go(self, page):
303
+ page = max(1, min(self.pages, page))
304
+ if page != self.page:
305
+ self.page = page
306
+ if self.on_change is not None:
307
+ self.on_change(page)
308
+ self.refresh()
309
+ return True
310
+
311
+ def on_key(self, key):
312
+ k = str(key)
313
+ moves = {'left': -1, 'pageup': -1, 'right': 1, 'pagedown': 1}
314
+ if k in moves:
315
+ return self.go(self.page + moves[k])
316
+ if k == 'home':
317
+ return self.go(1)
318
+ if k == 'end':
319
+ return self.go(self.pages)
320
+ return False
321
+
322
+ def natural_width(self):
323
+ return 40
324
+
325
+ def numbers(self, slots=7):
326
+ pages, page = self.pages, self.page
327
+ if pages <= slots:
328
+ return list(range(1, pages + 1))
329
+ side = (slots - 3) // 2
330
+ if page <= side + 2:
331
+ return list(range(1, slots - 1)) + [None, pages]
332
+ if page >= pages - side - 1:
333
+ return [1, None] + list(range(pages - slots + 3, pages + 1))
334
+ return [1, None] + list(range(page - side + 1, page + side)) + \
335
+ [None, pages]
336
+
337
+ def draw(self, width, height):
338
+ start = (self.page - 1) * self.page_size + 1 if self.total else 0
339
+ end = min(self.total, self.page * self.page_size)
340
+ info = '{0}–{1} de {2}'.format(start, end, self.total)
341
+ parts = [self.paint('‹', self.theme.muted if self.page == 1 else None)]
342
+ for n in self.numbers():
343
+ if n is None:
344
+ parts.append(self.paint('…', self.theme.muted))
345
+ elif n == self.page:
346
+ spec = self.theme.selection if self.focused else 'reverse'
347
+ parts.append(self.paint(' {0} '.format(n), spec) if
348
+ self._active() else '[{0}]'.format(n))
349
+ else:
350
+ parts.append(str(n))
351
+ parts.append(self.paint('›', self.theme.muted if
352
+ self.page == self.pages else None))
353
+ line = ' '.join(parts)
354
+ if _fx.visible_width(line) + len(info) + 3 <= width:
355
+ line += ' ' + self.paint(info, self.theme.muted)
356
+ elif _fx.visible_width(line) > width:
357
+ line = '‹ {0}/{1} ›'.format(self.page, self.pages)
358
+ return [line]
359
+
360
+
361
+ class Sidebar(Menu):
362
+ """
363
+ Navigation down the side: ``sections`` maps a heading to items (as in
364
+ :class:`Menu`). ``collapsed=True`` (or Ctrl-B) shows only the icons.
365
+ """
366
+
367
+ expand = True
368
+
369
+ def __init__(self, sections, collapsed=False, title=None, **kwargs):
370
+ items = []
371
+ if isinstance(sections, dict):
372
+ for heading, entries in sections.items():
373
+ if heading:
374
+ items.append({'label': heading, 'heading': True,
375
+ 'disabled': True})
376
+ items.extend(entries)
377
+ else:
378
+ items = list(sections)
379
+ super(Sidebar, self).__init__(items, **kwargs)
380
+ self.collapsed = bool(collapsed)
381
+ self.title = title
382
+ self.active = self.index
383
+
384
+ def natural_width(self):
385
+ if self.collapsed:
386
+ return 4
387
+ return max(super(Sidebar, self).natural_width(),
388
+ _fx.visible_width(self.title or '') + 2) + 1
389
+
390
+ def activate(self, i=None):
391
+ done = super(Sidebar, self).activate(i)
392
+ if done:
393
+ self.active = self.index
394
+ return done
395
+
396
+ def on_key(self, key):
397
+ if str(key) == 'ctrl+b':
398
+ self.collapsed = not self.collapsed
399
+ self.refresh()
400
+ return True
401
+ return super(Sidebar, self).on_key(key)
402
+
403
+ def draw(self, width, height):
404
+ out = []
405
+ if self.title and not self.collapsed:
406
+ out.append(self.paint(' ' + self.title, 'bold ' +
407
+ self.theme.accent))
408
+ out.append('')
409
+ for i, item in enumerate(self.items):
410
+ if item.get('heading'):
411
+ if not self.collapsed:
412
+ out.append(self.paint(' ' + item['label'].upper(),
413
+ self.theme.muted))
414
+ continue
415
+ if item.get('separator'):
416
+ out.append(self.paint('─' * width, self.theme.border))
417
+ continue
418
+ icon = item.get('icon') or item['label'][:1]
419
+ text = ' ' + icon + ' ' if self.collapsed else \
420
+ ' ' + self._text(item)
421
+ text = _fx.fit_line(text, width)
422
+ if i == self.index and self.focused:
423
+ text = self.paint(text, self.theme.selection) if \
424
+ self._active() else '>' + text[1:]
425
+ elif i == self.active:
426
+ text = self.paint('▌', self.theme.accent) + \
427
+ self.paint(text[1:], 'bold') if self._active() else \
428
+ '*' + text[1:]
429
+ elif item.get('disabled'):
430
+ text = self.paint(text, 'dim')
431
+ out.append(text)
432
+ return out
433
+
434
+
435
+ class TopBar(_Themed):
436
+ """
437
+ The bar across the top: ``title`` (and ``breadcrumbs``) on the left,
438
+ ``actions`` -- text or ``(key, label)`` pairs -- and ``status`` on the
439
+ right.
440
+ """
441
+
442
+ def __init__(self, title='', breadcrumbs=None, actions=None, status=None,
443
+ color=None, **kwargs):
444
+ super(TopBar, self).__init__(**kwargs)
445
+ self.title = title
446
+ self.breadcrumbs = breadcrumbs or []
447
+ self.actions = actions or []
448
+ self.status = status
449
+ self.color = color
450
+
451
+ def natural_width(self):
452
+ return 60
453
+
454
+ def draw(self, width, height):
455
+ color = self.color or 'black on ' + self.theme.accent
456
+ left = ' ' + self.paint(self.title, 'bold')
457
+ if self.breadcrumbs:
458
+ left += ' ' + ' › '.join(str(b) for b in self.breadcrumbs)
459
+ right_parts = []
460
+ for action in self.actions:
461
+ if isinstance(action, (tuple, list)):
462
+ right_parts.append('{0} {1}'.format(action[0], action[1]))
463
+ else:
464
+ right_parts.append(str(action))
465
+ if self.status:
466
+ right_parts.append(str(self.status))
467
+ right = ' '.join(right_parts) + ' '
468
+ room = width - _fx.visible_width(right)
469
+ if room < _fx.visible_width(left):
470
+ left = _fx.truncate(left, max(0, room - 1), '…')
471
+ line = _fx.fit_line(left, max(0, room)) + right
472
+ line = _fx.fit_line(line, width)
473
+ return [self.paint(line, color) if self._active() else line]
474
+
475
+
476
+ class Toolbar(_Themed):
477
+ """
478
+ A row of :class:`Button` s (or ``(label, action)`` pairs): Left/Right
479
+ move between them while one has focus; each keeps its own shortcut.
480
+ """
481
+
482
+ def __init__(self, buttons, gap=1, align='l', **kwargs):
483
+ super(Toolbar, self).__init__(**kwargs)
484
+ self.buttons = [b if isinstance(b, Button) else
485
+ Button(b[0], b[1], theme=self.theme,
486
+ use_colors=kwargs.get('use_colors'))
487
+ for b in buttons]
488
+ self.gap = gap
489
+ self.align = align
490
+
491
+ def children(self):
492
+ return list(self.buttons)
493
+
494
+ def natural_width(self):
495
+ return sum(b.natural_width() for b in self.buttons) + \
496
+ self.gap * (len(self.buttons) - 1)
497
+
498
+ def handle_key(self, key):
499
+ k = str(key)
500
+ if self.app is not None and self.app.focused in self.buttons and \
501
+ k in ('left', 'right'):
502
+ i = self.buttons.index(self.app.focused)
503
+ j = (i + (1 if k == 'right' else -1)) % len(self.buttons)
504
+ self.app.focus(self.buttons[j])
505
+ return True
506
+ for button in self.buttons:
507
+ if button.shortcut and k == button.shortcut and \
508
+ not button.disabled:
509
+ return button.press()
510
+ return super(Toolbar, self).handle_key(key)
511
+
512
+ def draw(self, width, height):
513
+ parts = [b.render(width=b.natural_width()) for b in self.buttons]
514
+ line = (' ' * self.gap).join(parts)
515
+ return [_fx.fit_line(line, width, self.align)]
516
+
517
+
518
+ class Wizard(_Themed):
519
+ """
520
+ Steps with a stepper on top, the current step's content, and Back /
521
+ Next / Finish buttons. ``steps`` are ``(title, content)`` pairs;
522
+ ``validate(index) -> error or None`` guards Next; ``on_finish()``.
523
+ Ctrl-N / Ctrl-P move too.
524
+ """
525
+
526
+ expand = True
527
+
528
+ def __init__(self, steps, on_finish=None, validate=None, **kwargs):
529
+ super(Wizard, self).__init__(**kwargs)
530
+ self.steps = list(steps)
531
+ self.index = 0
532
+ self.on_finish = on_finish
533
+ self.check = validate
534
+ self.error = None
535
+ self.finished = False
536
+ colors = kwargs.get('use_colors')
537
+ self.back = Button('‹ Atrás', self.previous, theme=self.theme,
538
+ use_colors=colors)
539
+ self.next = Button('Siguiente ›', self.advance, kind='primary',
540
+ theme=self.theme, use_colors=colors)
541
+
542
+ def children(self):
543
+ content = self.steps[self.index][1] if self.steps else None
544
+ return ([content] if content is not None else []) + \
545
+ [self.back, self.next]
546
+
547
+ def natural_width(self):
548
+ return max([60] + [_natural_width(c) for _, c in self.steps])
549
+
550
+ def previous(self):
551
+ if self.index > 0:
552
+ self.index -= 1
553
+ self.error = None
554
+ self.refresh()
555
+ return True
556
+
557
+ def advance(self):
558
+ if self.check is not None:
559
+ self.error = self.check(self.index)
560
+ if self.error:
561
+ self.refresh()
562
+ return True
563
+ if self.index + 1 < len(self.steps):
564
+ self.index += 1
565
+ else:
566
+ self.finished = True
567
+ if self.on_finish is not None:
568
+ self.on_finish()
569
+ self.refresh()
570
+ return True
571
+
572
+ def handle_key(self, key):
573
+ k = str(key)
574
+ if k == 'ctrl+n':
575
+ return self.advance()
576
+ if k == 'ctrl+p':
577
+ return self.previous()
578
+ return super(Wizard, self).handle_key(key)
579
+
580
+ def draw(self, width, height):
581
+ from .display import Stepper
582
+ stepper = Stepper([t for t, _ in self.steps], self.index,
583
+ theme=self.theme, use_colors=self.use_colors)
584
+ out = render_into(stepper, width) + ['']
585
+ self.back.disabled = self.index == 0
586
+ last = self.index + 1 == len(self.steps)
587
+ self.next.label = 'Finalizar ✔' if last else 'Siguiente ›'
588
+ content = self.steps[self.index][1] if self.steps else ''
589
+ room = None if height is None else max(0, height - len(out) - 3)
590
+ body = render_into(content, width, room)
591
+ if room is not None:
592
+ body += [''] * (room - len(body))
593
+ out.extend(body)
594
+ if self.error:
595
+ out.append(self.paint('✖ ' + self.error, self.theme.error))
596
+ else:
597
+ out.append('')
598
+ buttons = self.back.render(width=self.back.natural_width()) + ' ' + \
599
+ self.next.render(width=self.next.natural_width())
600
+ out.append(_fx.fit_line(buttons, width, 'r'))
601
+ return out
602
+
603
+
604
+ class Accordion(_Themed):
605
+ """
606
+ Collapsible sections: ``sections`` are ``(title, content)`` pairs. Up/Down
607
+ move between headers, Enter/Space toggle; ``multiple=False`` keeps one
608
+ open at a time. A single section works as a collapsible panel.
609
+ """
610
+
611
+ focusable = True
612
+
613
+ def __init__(self, sections, open=(0,), multiple=True, **kwargs):
614
+ super(Accordion, self).__init__(**kwargs)
615
+ self.sections = list(sections.items()) if isinstance(sections, dict) \
616
+ else list(sections)
617
+ self.opened = set(open or ())
618
+ self.multiple = bool(multiple)
619
+ self.index = 0
620
+
621
+ def children(self):
622
+ return [c for i, (_, c) in enumerate(self.sections)
623
+ if i in self.opened and not isinstance(c, str)]
624
+
625
+ def natural_width(self):
626
+ return max([_natural_width(c) + 2 for _, c in self.sections] +
627
+ [_fx.visible_width(t) + 4 for t, _ in self.sections])
628
+
629
+ def toggle(self, i=None):
630
+ i = self.index if i is None else i
631
+ if i in self.opened:
632
+ self.opened.discard(i)
633
+ else:
634
+ if not self.multiple:
635
+ self.opened.clear()
636
+ self.opened.add(i)
637
+ self.refresh()
638
+ return True
639
+
640
+ def on_key(self, key):
641
+ k = str(key)
642
+ if k in ('up', 'down') and self.sections:
643
+ self.index = (self.index + (1 if k == 'down' else -1)) % \
644
+ len(self.sections)
645
+ self.refresh()
646
+ return True
647
+ if k in ('enter', 'space'):
648
+ return self.toggle()
649
+ if k == 'right' and self.index not in self.opened:
650
+ return self.toggle()
651
+ if k == 'left' and self.index in self.opened:
652
+ return self.toggle()
653
+ return False
654
+
655
+ def draw(self, width, height):
656
+ out = []
657
+ for i, (title, content) in enumerate(self.sections):
658
+ is_open = i in self.opened
659
+ arrow = ('▾' if is_open else '▸') if self._active() else \
660
+ ('v' if is_open else '>')
661
+ head = _fx.fit_line(arrow + ' ' + str(title), width)
662
+ if i == self.index and self.focused:
663
+ head = self.paint(head, self.theme.selection) if \
664
+ self._active() else '*' + head[1:]
665
+ else:
666
+ head = self.paint(head, 'bold')
667
+ out.append(head)
668
+ if is_open:
669
+ out.extend(' ' + line for line in
670
+ render_into(content, max(1, width - 2)))
671
+ return out
672
+
673
+
674
+ class Carousel(_Themed):
675
+ """Slides one at a time with ``◀ ● ○ ○ ▶``; Left/Right change slide,
676
+ ``autoplay`` (in ticks) advances on :meth:`tick`."""
677
+
678
+ focusable = True
679
+
680
+ def __init__(self, slides, index=0, autoplay=None, **kwargs):
681
+ super(Carousel, self).__init__(**kwargs)
682
+ self.slides = list(slides)
683
+ self.index = index
684
+ self.autoplay = autoplay
685
+ self._ticks = 0
686
+
687
+ def natural_width(self):
688
+ return max([_natural_width(s) for s in self.slides] + [20])
689
+
690
+ def go(self, step):
691
+ if self.slides:
692
+ self.index = (self.index + step) % len(self.slides)
693
+ self.refresh()
694
+ return True
695
+
696
+ def tick(self):
697
+ if self.autoplay:
698
+ self._ticks += 1
699
+ if self._ticks >= self.autoplay:
700
+ self._ticks = 0
701
+ self.go(1)
702
+
703
+ def on_key(self, key):
704
+ k = str(key)
705
+ if k in ('left', 'right'):
706
+ return self.go(1 if k == 'right' else -1)
707
+ return False
708
+
709
+ def draw(self, width, height):
710
+ room = None if height is None else max(0, height - 1)
711
+ body = render_into(self.slides[self.index] if self.slides else '',
712
+ width, room)
713
+ dots = ' '.join(('●' if i == self.index else '○') if self._active()
714
+ else ('*' if i == self.index else '.')
715
+ for i in range(len(self.slides)))
716
+ nav = self.paint('◀', self.theme.accent if self.focused else
717
+ self.theme.muted) + ' ' + dots + ' ' + \
718
+ self.paint('▶', self.theme.accent if self.focused else
719
+ self.theme.muted)
720
+ if room is not None:
721
+ body += [''] * (room - len(body))
722
+ return body + [_fx.fit_line(nav, width, 'c')]
723
+
724
+
725
+ class Tree(_Themed):
726
+ """
727
+ A collapsible tree. ``data`` is nested dicts / lists (an object
728
+ inspector) or ``(label, children)`` nodes. Up/Down move, Right/Enter
729
+ expand, Left collapses or goes to the parent, ``*`` expands everything.
730
+ ``on_select(path, value)`` runs on Enter for a leaf.
731
+ """
732
+
733
+ focusable = True
734
+ expand = True
735
+
736
+ def __init__(self, data, expanded_depth=1, on_select=None,
737
+ max_value_width=40, **kwargs):
738
+ super(Tree, self).__init__(**kwargs)
739
+ self.data = data
740
+ self.on_select = on_select
741
+ self.max_value_width = max_value_width
742
+ self.expanded = set()
743
+ self.index = 0
744
+ self.top = 0
745
+ self._expand_to(data, (), expanded_depth)
746
+
747
+ @staticmethod
748
+ def _kids(value):
749
+ if isinstance(value, dict):
750
+ return list(value.items())
751
+ if isinstance(value, (list, tuple)) and not (
752
+ len(value) == 2 and isinstance(value[0], str) and
753
+ isinstance(value[1], (list, tuple, dict))):
754
+ return [('[{0}]'.format(i), v) for i, v in enumerate(value)]
755
+ if isinstance(value, tuple) and len(value) == 2:
756
+ return [(c[0], c[1]) if isinstance(c, tuple) else (str(c), None)
757
+ for c in value[1]]
758
+ return None
759
+
760
+ def _expand_to(self, value, path, depth):
761
+ kids = self._kids(value)
762
+ if kids is None or depth <= 0:
763
+ return
764
+ self.expanded.add(path)
765
+ for key, child in kids:
766
+ self._expand_to(child, path + (key,), depth - 1)
767
+
768
+ def rows(self):
769
+ out = []
770
+
771
+ def walk(value, path, depth):
772
+ for key, child in self._kids(value) or []:
773
+ p = path + (key,)
774
+ kids = self._kids(child)
775
+ out.append((p, key, child, depth, kids is not None))
776
+ if kids is not None and p in self.expanded:
777
+ walk(child, p, depth + 1)
778
+ walk(self.data, (), 0)
779
+ return out
780
+
781
+ def natural_width(self):
782
+ return 40
783
+
784
+ def on_key(self, key):
785
+ k = str(key)
786
+ rows = self.rows()
787
+ if not rows:
788
+ return False
789
+ self.index = min(self.index, len(rows) - 1)
790
+ path, _, value, depth, branch = rows[self.index]
791
+ if k in ('up', 'k'):
792
+ self.index = max(0, self.index - 1)
793
+ elif k in ('down', 'j'):
794
+ self.index = min(len(rows) - 1, self.index + 1)
795
+ elif k in ('right', 'l') and branch:
796
+ self.expanded.add(path)
797
+ elif k == 'enter':
798
+ if branch:
799
+ self.expanded.symmetric_difference_update([path])
800
+ elif self.on_select is not None:
801
+ self.on_select(path, value)
802
+ elif k in ('left', 'h'):
803
+ if branch and path in self.expanded:
804
+ self.expanded.discard(path)
805
+ elif len(path) > 1:
806
+ parent = path[:-1]
807
+ self.index = next(i for i, r in enumerate(rows)
808
+ if r[0] == parent)
809
+ elif k == '*':
810
+ self._expand_to(self.data, (), 99)
811
+ elif k in ('pageup', 'pagedown'):
812
+ step = 10 if k == 'pagedown' else -10
813
+ self.index = max(0, min(len(rows) - 1, self.index + step))
814
+ else:
815
+ return False
816
+ self.refresh()
817
+ return True
818
+
819
+ def _value_text(self, value, branch):
820
+ if branch:
821
+ n = len(self._kids(value) or [])
822
+ kind = '{…}' if isinstance(value, dict) else '[…]'
823
+ return self.paint('{0} {1}'.format(kind, n), self.theme.muted)
824
+ if value is None:
825
+ return self.paint('null', self.theme.muted)
826
+ if isinstance(value, bool):
827
+ return self.paint(str(value).lower(), self.theme.accent)
828
+ if isinstance(value, (int, float)):
829
+ return self.paint(str(value), self.theme.warning)
830
+ return self.paint(_fx.truncate('"{0}"'.format(value),
831
+ self.max_value_width, '…'),
832
+ self.theme.success)
833
+
834
+ def draw(self, width, height):
835
+ rows = self.rows()
836
+ view = height or len(rows)
837
+ self.index = min(self.index, max(0, len(rows) - 1))
838
+ if self.index < self.top:
839
+ self.top = self.index
840
+ if self.index >= self.top + view:
841
+ self.top = self.index - view + 1
842
+ out = []
843
+ for i in range(self.top, min(len(rows), self.top + view)):
844
+ path, key, value, depth, branch = rows[i]
845
+ if branch:
846
+ arrow = ('▾' if path in self.expanded else '▸') if \
847
+ self._active() else ('-' if path in self.expanded else '+')
848
+ else:
849
+ arrow = ' '
850
+ label = ' ' * depth + arrow + ' ' + str(key)
851
+ line = _fx.fit_line(label, min(width, _fx.visible_width(label)))
852
+ if i == self.index and self.focused:
853
+ line = self.paint(line, self.theme.selection) if \
854
+ self._active() else '>' + line[1:]
855
+ value_text = self._value_text(value, branch) if \
856
+ isinstance(self.data, (dict, list)) or branch else ''
857
+ out.append(line + (': ' if value_text and not branch else ' ') +
858
+ value_text if value_text else line)
859
+ return out
860
+
861
+
862
+ class CommandPalette(_Themed):
863
+ """
864
+ A search box over commands -- ``(label, action)`` pairs or dicts with
865
+ ``label``, ``action``, ``hint``, ``keywords`` -- matched by fuzzy
866
+ subsequence, best first. Up/Down choose, Enter runs, Escape closes.
867
+ Open it with ``open(app)`` (bind it to ``ctrl+k`` or ``/``).
868
+ """
869
+
870
+ focusable = True
871
+
872
+ def __init__(self, commands, placeholder='Buscar…', max_visible=8,
873
+ title='Comandos', **kwargs):
874
+ super(CommandPalette, self).__init__(**kwargs)
875
+ self.commands = [c if isinstance(c, dict) else
876
+ {'label': c[0], 'action': c[1] if len(c) > 1
877
+ else None} for c in commands]
878
+ self.placeholder = placeholder
879
+ self.max_visible = max_visible
880
+ self.title = title
881
+ self.query = ''
882
+ self.index = 0
883
+ self.ran = []
884
+
885
+ def natural_width(self):
886
+ return 60
887
+
888
+ @staticmethod
889
+ def score(query, text):
890
+ """Lower is better; None when ``query`` is not a subsequence."""
891
+ q, t = query.lower(), text.lower()
892
+ if not q:
893
+ return 0
894
+ if q in t:
895
+ return t.index(q)
896
+ pos, gaps, last = 0, 0, -1
897
+ for ch in q:
898
+ pos = t.find(ch, pos)
899
+ if pos < 0:
900
+ return None
901
+ if last >= 0:
902
+ gaps += pos - last - 1
903
+ last = pos
904
+ pos += 1
905
+ return 100 + gaps
906
+
907
+ def matches(self):
908
+ scored = []
909
+ for n, command in enumerate(self.commands):
910
+ text = command['label'] + ' ' + ' '.join(
911
+ command.get('keywords', []))
912
+ s = self.score(self.query, text)
913
+ if s is not None:
914
+ scored.append((s, n, command))
915
+ scored.sort(key=lambda x: (x[0], x[1]))
916
+ return [c for _, _, c in scored]
917
+
918
+ def open(self, app):
919
+ self.query = ''
920
+ self.index = 0
921
+ app.push(self, position='top')
922
+ app.focus(self)
923
+ return True
924
+
925
+ def close(self):
926
+ if self.app is not None:
927
+ self.app.pop(self)
928
+
929
+ def on_key(self, key):
930
+ k = str(key)
931
+ items = self.matches()
932
+ if k == 'escape':
933
+ self.close()
934
+ elif k == 'up':
935
+ self.index = max(0, self.index - 1)
936
+ elif k == 'down':
937
+ self.index = min(max(0, len(items) - 1), self.index + 1)
938
+ elif k == 'enter':
939
+ if items:
940
+ command = items[min(self.index, len(items) - 1)]
941
+ self.ran.append(command['label'])
942
+ self.close()
943
+ if command.get('action') is not None:
944
+ command['action']()
945
+ elif k == 'backspace':
946
+ self.query = self.query[:-1]
947
+ self.index = 0
948
+ elif k == 'paste' or key.is_printable or k == 'space':
949
+ self.query += key.char or ' '
950
+ self.index = 0
951
+ elif k in ('tab', 'shift+tab'):
952
+ return True
953
+ else:
954
+ return False
955
+ self.refresh()
956
+ return True
957
+
958
+ def draw(self, width, height):
959
+ inner = max(1, width - 4)
960
+ query = self.query or self.paint(self.placeholder, self.theme.muted)
961
+ lines = [self.paint('› ', self.theme.accent) + query +
962
+ (self.cursor(' ') if self.focused else ''),
963
+ self.paint('─' * inner, self.theme.border)]
964
+ items = self.matches()
965
+ for i, command in enumerate(items[:self.max_visible]):
966
+ hint = command.get('hint') or ''
967
+ room = max(1, inner - len(hint) - 1)
968
+ text = _fx.fit_line(' ' + command['label'], room) + ' ' + hint
969
+ if i == self.index:
970
+ text = self.paint(_fx.fit_line(text, inner),
971
+ self.theme.selection) if self._active() \
972
+ else '>' + text[1:]
973
+ elif hint:
974
+ text = text[:-len(hint)] + self.paint(hint, self.theme.muted)
975
+ lines.append(text)
976
+ if not items:
977
+ lines.append(self.paint(' Sin resultados', self.theme.muted))
978
+ return frame([' ' + l for l in lines], width, self.title,
979
+ self.theme.accent, self.paint)
980
+
981
+
982
+ class Split(_Themed):
983
+ """
984
+ Two panes side by side (``direction='horizontal'``) or stacked
985
+ (``'vertical'``), divided at ``ratio``; Ctrl-Left/Right (Ctrl-Up/Down)
986
+ move the divider.
987
+ """
988
+
989
+ expand = True
990
+
991
+ def __init__(self, first, second, ratio=0.5, direction='horizontal',
992
+ divider=True, minimum=8, **kwargs):
993
+ super(Split, self).__init__(**kwargs)
994
+ self.first = first
995
+ self.second = second
996
+ self.ratio = ratio
997
+ self.direction = direction
998
+ self.divider = bool(divider)
999
+ self.minimum = minimum
1000
+
1001
+ def children(self):
1002
+ return [self.first, self.second]
1003
+
1004
+ def natural_width(self):
1005
+ if self.direction == 'vertical':
1006
+ return max(_natural_width(self.first),
1007
+ _natural_width(self.second))
1008
+ return _natural_width(self.first) + _natural_width(self.second) + 3
1009
+
1010
+ def handle_key(self, key):
1011
+ k = str(key)
1012
+ grow = {'ctrl+right': 0.05, 'ctrl+left': -0.05, 'ctrl+down': 0.05,
1013
+ 'ctrl+up': -0.05}
1014
+ horizontal = self.direction != 'vertical'
1015
+ if k in grow and (('left' in k or 'right' in k) == horizontal):
1016
+ self.ratio = max(0.1, min(0.9, self.ratio + grow[k]))
1017
+ self.refresh()
1018
+ return True
1019
+ return super(Split, self).handle_key(key)
1020
+
1021
+ def draw(self, width, height):
1022
+ if self.direction == 'vertical':
1023
+ total = height or (len(render_into(self.first, width)) +
1024
+ len(render_into(self.second, width)) + 1)
1025
+ avail = total - (1 if self.divider else 0)
1026
+ top_h = max(1, min(avail - 1, int(round(avail * self.ratio))))
1027
+ top = render_into(self.first, width, top_h)
1028
+ top += [''] * (top_h - len(top))
1029
+ bottom = render_into(self.second, width, avail - top_h)
1030
+ mid = [self.paint('─' * width, self.theme.border)] \
1031
+ if self.divider else []
1032
+ return top + mid + bottom
1033
+ gap = 3 if self.divider else 1
1034
+ avail = max(2, width - gap)
1035
+ left_w = max(min(self.minimum, avail - 1), min(
1036
+ avail - self.minimum, int(round(avail * self.ratio))))
1037
+ left_w = max(1, min(avail - 1, left_w))
1038
+ right_w = avail - left_w
1039
+ left = render_into(self.first, left_w, height)
1040
+ right = render_into(self.second, right_w, height)
1041
+ rows = height or max(len(left), len(right))
1042
+ sep = ' ' + self.paint('│', self.theme.border) + ' ' if \
1043
+ self.divider else ' '
1044
+ out = []
1045
+ for k in range(rows):
1046
+ out.append(_fx.fit_line(left[k] if k < len(left) else '', left_w) +
1047
+ sep + (right[k] if k < len(right) else ''))
1048
+ return out
1049
+
1050
+
1051
+ class Stack(_Themed):
1052
+ """
1053
+ Children one below the other (``direction='vertical'``) or side by side
1054
+ (``'horizontal'``, equal widths unless ``widths`` given), with ``gap``.
1055
+ A horizontal stack with ``min_width`` wraps onto more rows when its
1056
+ children would get narrower than that (four cards become 2 x 2, then a
1057
+ column).
1058
+ """
1059
+
1060
+ def __init__(self, children, direction='vertical', gap=0, widths=None,
1061
+ min_width=None, **kwargs):
1062
+ super(Stack, self).__init__(**kwargs)
1063
+ self.items = list(children)
1064
+ self.direction = direction
1065
+ self.gap = max(0, int(gap))
1066
+ self.widths = widths
1067
+ self.min_width = min_width
1068
+
1069
+ def children(self):
1070
+ return list(self.items)
1071
+
1072
+ def natural_width(self):
1073
+ widths = [_natural_width(c) for c in self.items] or [0]
1074
+ if self.direction == 'horizontal':
1075
+ return sum(widths) + self.gap * (len(widths) - 1)
1076
+ return max(widths)
1077
+
1078
+ def _row(self, items, slots, width, height):
1079
+ if self.widths:
1080
+ widths = list(self.widths)
1081
+ else:
1082
+ each = (width - self.gap * (slots - 1)) // slots
1083
+ widths = [each] * slots
1084
+ widths[-1] = width - self.gap * (slots - 1) - each * (slots - 1)
1085
+ widths = widths[:len(items)]
1086
+ blocks = [render_into(c, max(1, w), height)
1087
+ for c, w in zip(items, widths)]
1088
+ return join_columns(blocks, widths, self.gap)
1089
+
1090
+ def draw(self, width, height):
1091
+ if self.direction == 'horizontal':
1092
+ per_row = len(self.items) or 1
1093
+ if self.min_width and not self.widths:
1094
+ per_row = max(1, min(per_row, (width + self.gap) //
1095
+ (int(self.min_width) + self.gap)))
1096
+ # Balanced rows: 4 cards that fit 3 across become 2 x 2.
1097
+ rows = -(-len(self.items) // per_row)
1098
+ per_row = -(-len(self.items) // rows)
1099
+ if per_row < len(self.items):
1100
+ out = []
1101
+ for start in range(0, len(self.items), per_row):
1102
+ if start:
1103
+ out.extend([''] * self.gap)
1104
+ out.extend(self._row(self.items[start:start + per_row],
1105
+ per_row, width, None))
1106
+ return out
1107
+ return self._row(self.items, per_row, width, height)
1108
+ out = []
1109
+ for i, child in enumerate(self.items):
1110
+ if i and self.gap:
1111
+ out.extend([''] * self.gap)
1112
+ out.extend(render_into(child, width))
1113
+ return out
1114
+
1115
+
1116
+ class SortControl(_Themed):
1117
+ """
1118
+ Choose the sort field and direction: Left/Right pick the field, Space or
1119
+ Enter flips ascending/descending. ``on_change(field, descending)``.
1120
+ """
1121
+
1122
+ focusable = True
1123
+
1124
+ def __init__(self, fields, field=None, descending=False, on_change=None,
1125
+ **kwargs):
1126
+ super(SortControl, self).__init__(**kwargs)
1127
+ self.fields = list(fields)
1128
+ self.field = field if field is not None else (self.fields[0] if
1129
+ self.fields else None)
1130
+ self.descending = bool(descending)
1131
+ self.on_change = on_change
1132
+
1133
+ def natural_width(self):
1134
+ return sum(len(str(f)) + 3 for f in self.fields) + 8
1135
+
1136
+ def _changed(self):
1137
+ if self.on_change is not None:
1138
+ self.on_change(self.field, self.descending)
1139
+ self.refresh()
1140
+ return True
1141
+
1142
+ def on_key(self, key):
1143
+ k = str(key)
1144
+ if k in ('left', 'right') and self.fields:
1145
+ i = self.fields.index(self.field) if self.field in self.fields \
1146
+ else 0
1147
+ self.field = self.fields[(i + (1 if k == 'right' else -1)) %
1148
+ len(self.fields)]
1149
+ return self._changed()
1150
+ if k in ('space', 'enter'):
1151
+ self.descending = not self.descending
1152
+ return self._changed()
1153
+ return False
1154
+
1155
+ def draw(self, width, height):
1156
+ parts = [self.paint('Ordenar:', self.theme.muted)]
1157
+ for f in self.fields:
1158
+ if f == self.field:
1159
+ arrow = '↓' if self.descending else '↑'
1160
+ text = '{0} {1}'.format(f, arrow)
1161
+ parts.append(self.paint(' ' + text + ' ', self.theme.selection
1162
+ if self.focused else 'reverse') if
1163
+ self._active() else '[' + text + ']')
1164
+ else:
1165
+ parts.append(str(f))
1166
+ return [' '.join(parts)]
1167
+
1168
+
1169
+ class AppShell(_Themed):
1170
+ """
1171
+ The frame of an application: ``sidebar`` on the left, ``topbar`` above
1172
+ the content, ``content`` the current page. ``pages`` maps sidebar labels
1173
+ to content, so choosing an item in the sidebar shows its page. The
1174
+ sidebar hides itself below ``sidebar_below`` columns (Ctrl-B shows it).
1175
+ """
1176
+
1177
+ expand = True
1178
+
1179
+ def __init__(self, content=None, sidebar=None, topbar=None, pages=None,
1180
+ sidebar_below=70, **kwargs):
1181
+ super(AppShell, self).__init__(**kwargs)
1182
+ self.content = content
1183
+ self.sidebar = sidebar
1184
+ self.topbar = topbar
1185
+ self.pages = dict(pages or {})
1186
+ self.sidebar_below = sidebar_below
1187
+ self.show_sidebar = True
1188
+ self.force_sidebar = False
1189
+ self._width = 200
1190
+ if sidebar is not None and self.pages:
1191
+ previous = sidebar.on_select
1192
+
1193
+ def choose(index, item):
1194
+ self.open_page(item['label'])
1195
+ if previous is not None:
1196
+ previous(index, item)
1197
+ sidebar.on_select = choose
1198
+ if content is None:
1199
+ first = next((i for i in sidebar.items
1200
+ if i['label'] in self.pages), None)
1201
+ if first is not None:
1202
+ self.content = self.pages[first['label']]
1203
+
1204
+ def open_page(self, name):
1205
+ if name in self.pages:
1206
+ self.content = self.pages[name]
1207
+ if isinstance(self.topbar, TopBar):
1208
+ self.topbar.breadcrumbs = [name]
1209
+ self.refresh()
1210
+
1211
+ def sidebar_visible(self, width=None):
1212
+ width = self._width if width is None else width
1213
+ return self.sidebar is not None and self.show_sidebar and (
1214
+ width >= self.sidebar_below or self.force_sidebar)
1215
+
1216
+ def children(self):
1217
+ kids = []
1218
+ if self.sidebar_visible():
1219
+ kids.append(self.sidebar)
1220
+ if self.topbar is not None:
1221
+ kids.append(self.topbar)
1222
+ if self.content is not None:
1223
+ kids.append(self.content)
1224
+ return kids
1225
+
1226
+ def natural_width(self):
1227
+ return 100
1228
+
1229
+ def handle_key(self, key):
1230
+ if str(key) == 'ctrl+b' and self.sidebar is not None:
1231
+ if self._width < self.sidebar_below:
1232
+ self.force_sidebar = not self.force_sidebar
1233
+ self.show_sidebar = True
1234
+ else:
1235
+ self.show_sidebar = not self.show_sidebar
1236
+ self.refresh()
1237
+ return True
1238
+ return super(AppShell, self).handle_key(key)
1239
+
1240
+ def draw(self, width, height):
1241
+ self._width = width
1242
+ top = render_into(self.topbar, width) if self.topbar is not None \
1243
+ else []
1244
+ rows = None if height is None else max(0, height - len(top))
1245
+ if self.sidebar_visible(width):
1246
+ side_w = min(max(4, _natural_width(self.sidebar)), width // 3)
1247
+ side = render_into(self.sidebar, side_w, rows)
1248
+ main_w = max(1, width - side_w - 1)
1249
+ main = render_into(self.content, main_w, rows)
1250
+ n = rows or max(len(side), len(main))
1251
+ side += [''] * (n - len(side))
1252
+ bar = self.paint('│', self.theme.border)
1253
+ body = [_fx.fit_line(side[k], side_w) + bar +
1254
+ (main[k] if k < len(main) else '') for k in range(n)]
1255
+ else:
1256
+ body = render_into(self.content, width, rows)
1257
+ return top + body