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