flexablelib-tui 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_tui/__init__.py +27 -0
- flexablelib_tui/app.py +640 -0
- flexablelib_tui/keys.py +340 -0
- flexablelib_tui/py.typed +0 -0
- flexablelib_tui/screen.py +276 -0
- flexablelib_tui/terminal.py +239 -0
- flexablelib_tui/widgets.py +624 -0
- flexablelib_tui-3.3.0.dist-info/METADATA +41 -0
- flexablelib_tui-3.3.0.dist-info/RECORD +12 -0
- flexablelib_tui-3.3.0.dist-info/WHEEL +5 -0
- flexablelib_tui-3.3.0.dist-info/licenses/LICENSE +21 -0
- flexablelib_tui-3.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal interface toolkit for flexablelib (``pip install flexablelib[tui]``).
|
|
3
|
+
|
|
4
|
+
Widgets (progress bars, sparklines, bar charts, status bars, key hints, logs,
|
|
5
|
+
text, rules), full-screen layout (:class:`Screen`), in-place redraw that
|
|
6
|
+
writes only the changed lines (:class:`Live`), keyboard input on Linux, macOS
|
|
7
|
+
and Windows (:class:`Terminal`, :class:`KeyParser`), and interactive
|
|
8
|
+
applications with focus, key bindings, scrolling, dialogs and resize
|
|
9
|
+
(:class:`App`, :class:`Component`, :class:`ScrollView`).
|
|
10
|
+
|
|
11
|
+
Everything here is also reachable as ``flexablelib.ProgressBar``,
|
|
12
|
+
``flexablelib.App`` and so on once installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = '3.3.0'
|
|
16
|
+
|
|
17
|
+
from .widgets import (ProgressBar, Sparkline, BarChart, StatusBar, KeyHints,
|
|
18
|
+
LogView, Text, Rule)
|
|
19
|
+
from .screen import Screen, Live, render_height
|
|
20
|
+
from .keys import Key, KeyParser, WindowsKeyParser, parse_keys
|
|
21
|
+
from .terminal import Terminal
|
|
22
|
+
from .app import App, Component, ScrollView, overlay
|
|
23
|
+
|
|
24
|
+
__all__ = ['ProgressBar', 'Sparkline', 'BarChart', 'StatusBar', 'KeyHints',
|
|
25
|
+
'LogView', 'Text', 'Rule', 'Screen', 'Live', 'render_height',
|
|
26
|
+
'Key', 'KeyParser', 'WindowsKeyParser', 'parse_keys', 'Terminal',
|
|
27
|
+
'App', 'Component', 'ScrollView', 'overlay', '__version__']
|
flexablelib_tui/app.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""
|
|
2
|
+
INTERACTIVE APPLICATIONS
|
|
3
|
+
|
|
4
|
+
``Component``
|
|
5
|
+
The base of everything that takes keyboard focus: ``draw(width, height)``
|
|
6
|
+
returns its lines, ``on_key(key)`` handles a key (return True when it
|
|
7
|
+
did), ``bindings`` maps keys to callables. Components nest inside any
|
|
8
|
+
:class:`~flexablelib.Grid`, :class:`Screen` or other component, so a
|
|
9
|
+
layout of tables, cards and widgets becomes interactive by placing
|
|
10
|
+
components in it.
|
|
11
|
+
|
|
12
|
+
``App``
|
|
13
|
+
Runs the loop: draws the screen (only the changed lines reach the
|
|
14
|
+
terminal), reads keys, gives each to the focused component, then to its
|
|
15
|
+
parents, then to the app's own bindings. ``tab`` / ``shift+tab`` move the
|
|
16
|
+
focus between the components on screen, ``ctrl+c`` quits, a resized
|
|
17
|
+
window is redrawn at its new size, and ``push(layer)`` opens a dialog
|
|
18
|
+
over the screen that keeps the focus until ``pop()``.
|
|
19
|
+
|
|
20
|
+
``App.run(keys=[...], size=(80, 24))`` runs without a terminal -- for
|
|
21
|
+
tests, screenshots and scripted demos -- and returns the last frame.
|
|
22
|
+
|
|
23
|
+
``ScrollView``
|
|
24
|
+
A focusable viewport over content taller than its slot, with a scroll
|
|
25
|
+
bar; arrows, PgUp/PgDn, Home/End and the mouse wheel scroll it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import io
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
from flexablelib import _frames as _fx
|
|
32
|
+
from flexablelib.colors import RESET, parse_spec, supports_color
|
|
33
|
+
from flexablelib.layout import Grid, GridCell, _render_child, render_width
|
|
34
|
+
|
|
35
|
+
from .keys import Key
|
|
36
|
+
from .screen import Live, Screen
|
|
37
|
+
|
|
38
|
+
__all__ = ['Component', 'App', 'ScrollView', 'overlay']
|
|
39
|
+
|
|
40
|
+
# Frame counter: a component drawn in the latest frame is on screen, and only
|
|
41
|
+
# components on screen take focus.
|
|
42
|
+
_FRAME = [0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Component(object):
|
|
46
|
+
"""
|
|
47
|
+
Base class for interactive pieces.
|
|
48
|
+
|
|
49
|
+
Subclasses implement ``draw(width, height)`` returning a list of lines
|
|
50
|
+
(or one string) and, when they react to keys, ``on_key(key)``. Set
|
|
51
|
+
``focusable = True`` to take focus; ``self.focused`` says whether they
|
|
52
|
+
have it. ``natural_width()`` is the width a layout should offer.
|
|
53
|
+
``expand = True`` makes the component fill the height it is given.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
focusable = False
|
|
57
|
+
expand = False
|
|
58
|
+
use_colors = None
|
|
59
|
+
|
|
60
|
+
def __init__(self, bindings=None, use_colors=None, disabled=False):
|
|
61
|
+
self.focused = False
|
|
62
|
+
self.app = None
|
|
63
|
+
self.parent = None
|
|
64
|
+
self.disabled = bool(disabled)
|
|
65
|
+
self.bindings = dict(bindings or {})
|
|
66
|
+
if use_colors is not None:
|
|
67
|
+
self.use_colors = use_colors
|
|
68
|
+
self._drawn = -1
|
|
69
|
+
|
|
70
|
+
# -- colour -------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _active(self):
|
|
73
|
+
if self.use_colors is None:
|
|
74
|
+
return supports_color()
|
|
75
|
+
return bool(self.use_colors)
|
|
76
|
+
|
|
77
|
+
def paint(self, text, spec):
|
|
78
|
+
"""``text`` in colour ``spec`` (a flexablelib colour spec)."""
|
|
79
|
+
if not text or not spec or not self._active():
|
|
80
|
+
return text
|
|
81
|
+
prefix = parse_spec(spec)
|
|
82
|
+
if not prefix:
|
|
83
|
+
return text
|
|
84
|
+
return prefix + text.replace(RESET, RESET + prefix) + RESET
|
|
85
|
+
|
|
86
|
+
# -- layout protocol ----------------------------------------------------
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def can_focus(self):
|
|
90
|
+
return self.focusable and not self.disabled
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def on_screen(self):
|
|
94
|
+
"""Whether the component was drawn in the latest frame."""
|
|
95
|
+
return self._drawn == _FRAME[0]
|
|
96
|
+
|
|
97
|
+
def natural_width(self):
|
|
98
|
+
return 20
|
|
99
|
+
|
|
100
|
+
def draw(self, width, height): # pragma: no cover - abstract
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
def render(self, width=None, height=None):
|
|
104
|
+
width = render_width(width)
|
|
105
|
+
self._drawn = _FRAME[0]
|
|
106
|
+
lines = self.draw(width, height)
|
|
107
|
+
if isinstance(lines, str):
|
|
108
|
+
lines = lines.split('\n') if lines else []
|
|
109
|
+
if height is not None:
|
|
110
|
+
lines = lines[:height]
|
|
111
|
+
if self.expand and len(lines) < height:
|
|
112
|
+
lines = lines + [''] * (height - len(lines))
|
|
113
|
+
return '\n'.join(_fx.fit_lines(lines, width))
|
|
114
|
+
|
|
115
|
+
def __str__(self):
|
|
116
|
+
return self.render()
|
|
117
|
+
|
|
118
|
+
def children(self):
|
|
119
|
+
"""Components (or layouts holding components) inside this one."""
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
# -- input --------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def handle_key(self, key):
|
|
125
|
+
"""Bindings first, then ``on_key``; True when the key was used."""
|
|
126
|
+
action = self.bindings.get(str(key))
|
|
127
|
+
if action is not None:
|
|
128
|
+
result = action()
|
|
129
|
+
return result is not False
|
|
130
|
+
return bool(self.on_key(key))
|
|
131
|
+
|
|
132
|
+
def on_key(self, key):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def on_focus(self):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def on_blur(self):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def focus(self):
|
|
142
|
+
"""Ask the running app to focus this component."""
|
|
143
|
+
if self.app is not None:
|
|
144
|
+
self.app.focus(self)
|
|
145
|
+
|
|
146
|
+
def refresh(self):
|
|
147
|
+
if self.app is not None:
|
|
148
|
+
self.app.refresh()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def overlay(base, top, x, y, width):
|
|
152
|
+
"""Lay the lines of ``top`` over ``base`` at column ``x``, row ``y``."""
|
|
153
|
+
out = list(base)
|
|
154
|
+
for k, line in enumerate(top):
|
|
155
|
+
row = y + k
|
|
156
|
+
if row < 0 or row >= len(out):
|
|
157
|
+
continue
|
|
158
|
+
w = _fx.visible_width(line)
|
|
159
|
+
under = out[row]
|
|
160
|
+
left = _fx.slice_columns(under, 0, x) if x > 0 else ''
|
|
161
|
+
right_w = width - x - w
|
|
162
|
+
right = _fx.slice_columns(under, x + w, right_w) if right_w > 0 \
|
|
163
|
+
else ''
|
|
164
|
+
out[row] = left + line + right
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _child_nodes(node):
|
|
169
|
+
"""The direct children of anything a screen can hold."""
|
|
170
|
+
if isinstance(node, Component):
|
|
171
|
+
return list(node.children())
|
|
172
|
+
if isinstance(node, Screen):
|
|
173
|
+
return [n for n in (node.header, node.body, node.footer)
|
|
174
|
+
if n is not None]
|
|
175
|
+
if isinstance(node, Grid):
|
|
176
|
+
return list(node.children)
|
|
177
|
+
if isinstance(node, GridCell):
|
|
178
|
+
return [node.child]
|
|
179
|
+
if isinstance(node, App):
|
|
180
|
+
return [node.screen] + list(node.layers)
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class App(object):
|
|
185
|
+
"""
|
|
186
|
+
An interactive terminal application.
|
|
187
|
+
|
|
188
|
+
>>> app = App(body=grid, header=StatusBar('Panel'), footer=KeyHints(...),
|
|
189
|
+
... bindings={'q': app.exit, 'f5': reload})
|
|
190
|
+
>>> app.run()
|
|
191
|
+
|
|
192
|
+
``bindings`` maps keys to callables (``None`` removes a default one). The
|
|
193
|
+
defaults: ``ctrl+c`` quits, ``tab`` / ``shift+tab`` move the focus.
|
|
194
|
+
``interval`` (seconds) calls ``on_tick(app)`` that often and redraws, for
|
|
195
|
+
clocks and live data. ``mouse=True`` enables wheel and click reports.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, body=None, header=None, footer=None, bindings=None,
|
|
199
|
+
interval=None, on_tick=None, mouse=False, stream=None,
|
|
200
|
+
terminal=None, use_colors=None):
|
|
201
|
+
self.use_colors = use_colors
|
|
202
|
+
self.screen = Screen(body=body, header=header, footer=footer)
|
|
203
|
+
self.layers = []
|
|
204
|
+
self.focused = None
|
|
205
|
+
self.running = False
|
|
206
|
+
self.result = None
|
|
207
|
+
self.interval = interval
|
|
208
|
+
self.on_tick = on_tick
|
|
209
|
+
self.mouse = bool(mouse)
|
|
210
|
+
self.stream = stream
|
|
211
|
+
self.terminal = terminal
|
|
212
|
+
self.frames = 0
|
|
213
|
+
self.clock = time.monotonic
|
|
214
|
+
self.last_frame = ''
|
|
215
|
+
self._dirty = True
|
|
216
|
+
self._focus_stack = []
|
|
217
|
+
self.bindings = {'ctrl+c': self.exit, 'tab': self.focus_next,
|
|
218
|
+
'shift+tab': self.focus_previous}
|
|
219
|
+
for key, action in (bindings or {}).items():
|
|
220
|
+
if action is None:
|
|
221
|
+
self.bindings.pop(key, None)
|
|
222
|
+
else:
|
|
223
|
+
self.bindings[key] = action
|
|
224
|
+
|
|
225
|
+
# -- tree ---------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def walk(self, root=None):
|
|
228
|
+
"""Every node under ``root`` (default: the screen and dialogs)."""
|
|
229
|
+
stack = [(root if root is not None else self, None)]
|
|
230
|
+
while stack:
|
|
231
|
+
node, parent = stack.pop()
|
|
232
|
+
if isinstance(node, Component):
|
|
233
|
+
node.app = self
|
|
234
|
+
node.parent = parent
|
|
235
|
+
yield node
|
|
236
|
+
elif node is not self:
|
|
237
|
+
yield node
|
|
238
|
+
for child in reversed(_child_nodes(node)):
|
|
239
|
+
stack.append((child, node if isinstance(node, Component)
|
|
240
|
+
else parent))
|
|
241
|
+
|
|
242
|
+
def components(self):
|
|
243
|
+
return [n for n in self.walk() if isinstance(n, Component)]
|
|
244
|
+
|
|
245
|
+
def _modal(self):
|
|
246
|
+
modal = [layer for layer in self.layers
|
|
247
|
+
if getattr(layer, '_modal', True)]
|
|
248
|
+
return modal[-1] if modal else None
|
|
249
|
+
|
|
250
|
+
def focusables(self):
|
|
251
|
+
"""Components that can take focus now, in screen order."""
|
|
252
|
+
root = self._modal() or self.screen
|
|
253
|
+
return [n for n in self.walk(root)
|
|
254
|
+
if isinstance(n, Component) and n.can_focus and n.on_screen]
|
|
255
|
+
|
|
256
|
+
# -- focus --------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def focus(self, component):
|
|
259
|
+
if component is self.focused:
|
|
260
|
+
return
|
|
261
|
+
if self.focused is not None:
|
|
262
|
+
self.focused.focused = False
|
|
263
|
+
self.focused.on_blur()
|
|
264
|
+
self.focused = component
|
|
265
|
+
if component is not None:
|
|
266
|
+
component.app = self
|
|
267
|
+
component.focused = True
|
|
268
|
+
component.on_focus()
|
|
269
|
+
self._dirty = True
|
|
270
|
+
|
|
271
|
+
def _cycle(self, step):
|
|
272
|
+
items = self.focusables()
|
|
273
|
+
if not items:
|
|
274
|
+
return True
|
|
275
|
+
if self.focused in items:
|
|
276
|
+
i = (items.index(self.focused) + step) % len(items)
|
|
277
|
+
else:
|
|
278
|
+
i = 0 if step > 0 else len(items) - 1
|
|
279
|
+
self.focus(items[i])
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def focus_next(self):
|
|
283
|
+
return self._cycle(1)
|
|
284
|
+
|
|
285
|
+
def focus_previous(self):
|
|
286
|
+
return self._cycle(-1)
|
|
287
|
+
|
|
288
|
+
# -- dialogs ------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def push(self, layer, position='center', modal=True):
|
|
291
|
+
"""
|
|
292
|
+
Show ``layer`` over the screen. ``position`` is ``'center'``,
|
|
293
|
+
``'top'``, ``'bottom'``, ``'top-right'``, ``'bottom-right'`` or an
|
|
294
|
+
``(x, y)`` cell (negative counts from the right / bottom). A modal
|
|
295
|
+
layer keeps the focus until popped; ``modal=False`` floats over the
|
|
296
|
+
screen without taking keys (notifications, tooltips).
|
|
297
|
+
"""
|
|
298
|
+
layer._position = position
|
|
299
|
+
layer._modal = bool(modal)
|
|
300
|
+
self.layers.append(layer)
|
|
301
|
+
for node in self.walk(layer):
|
|
302
|
+
if isinstance(node, Component):
|
|
303
|
+
node.app = self
|
|
304
|
+
if modal:
|
|
305
|
+
self._focus_stack.append(self.focused)
|
|
306
|
+
self.focus(None)
|
|
307
|
+
self._dirty = True
|
|
308
|
+
return layer
|
|
309
|
+
|
|
310
|
+
def pop(self, layer=None):
|
|
311
|
+
"""Close the top dialog (or ``layer``) and give the focus back."""
|
|
312
|
+
if layer is None:
|
|
313
|
+
layer = self._modal()
|
|
314
|
+
if layer is None or layer not in self.layers:
|
|
315
|
+
return None
|
|
316
|
+
self.layers.remove(layer)
|
|
317
|
+
if getattr(layer, '_modal', True):
|
|
318
|
+
previous = self._focus_stack.pop() if self._focus_stack else None
|
|
319
|
+
self.focus(previous)
|
|
320
|
+
self._dirty = True
|
|
321
|
+
return layer
|
|
322
|
+
|
|
323
|
+
def notify(self, message, kind='info', seconds=3.0):
|
|
324
|
+
"""A short message in the top-right corner for ``seconds``."""
|
|
325
|
+
toast = _Toast(message, kind, self.clock() + seconds)
|
|
326
|
+
toast.use_colors = self.use_colors
|
|
327
|
+
toasts = [l for l in self.layers if isinstance(l, _Toast)]
|
|
328
|
+
self.push(toast, position=(-1, 1 + 3 * len(toasts)), modal=False)
|
|
329
|
+
return toast
|
|
330
|
+
|
|
331
|
+
# -- drawing ------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def refresh(self):
|
|
334
|
+
"""Mark the screen for redrawing."""
|
|
335
|
+
self._dirty = True
|
|
336
|
+
|
|
337
|
+
def _compose(self, width, height):
|
|
338
|
+
_FRAME[0] += 1
|
|
339
|
+
for node in self.walk(): # give every component its app
|
|
340
|
+
pass
|
|
341
|
+
now = self.clock()
|
|
342
|
+
expired = [l for l in self.layers
|
|
343
|
+
if isinstance(l, _Toast) and l.until <= now]
|
|
344
|
+
for layer in expired:
|
|
345
|
+
self.layers.remove(layer)
|
|
346
|
+
lines = self.screen.render(width=width, height=height).split('\n')
|
|
347
|
+
rows = len(lines)
|
|
348
|
+
for layer in self.layers:
|
|
349
|
+
want = getattr(layer, 'natural_width', lambda: width - 4)()
|
|
350
|
+
w = max(1, min(width - 2, want))
|
|
351
|
+
fixed = getattr(layer, 'height', None)
|
|
352
|
+
text = layer.render(width=w) if fixed is None else \
|
|
353
|
+
layer.render(width=w, height=min(rows - 2, fixed))
|
|
354
|
+
top = text.split('\n') if text else []
|
|
355
|
+
top = top[:rows]
|
|
356
|
+
x, y = self._place(getattr(layer, '_position', 'center'), w,
|
|
357
|
+
len(top), width, rows)
|
|
358
|
+
lines = overlay(lines, top, x, y, width)
|
|
359
|
+
return lines
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _place(position, w, h, width, rows):
|
|
363
|
+
if isinstance(position, (tuple, list)):
|
|
364
|
+
x, y = position
|
|
365
|
+
x = width - w + x + 1 if x < 0 else x
|
|
366
|
+
y = rows - h + y + 1 if y < 0 else y
|
|
367
|
+
elif position == 'top':
|
|
368
|
+
x, y = (width - w) // 2, 1
|
|
369
|
+
elif position == 'bottom':
|
|
370
|
+
x, y = (width - w) // 2, rows - h - 1
|
|
371
|
+
elif position == 'top-right':
|
|
372
|
+
x, y = width - w - 1, 1
|
|
373
|
+
elif position == 'bottom-right':
|
|
374
|
+
x, y = width - w - 1, rows - h - 1
|
|
375
|
+
else:
|
|
376
|
+
x, y = (width - w) // 2, (rows - h) // 2
|
|
377
|
+
return max(0, min(x, width - w)), max(0, min(y, rows - h))
|
|
378
|
+
|
|
379
|
+
def render(self, width=None, height=None):
|
|
380
|
+
width = render_width(width)
|
|
381
|
+
if height is None:
|
|
382
|
+
height = self.screen.height
|
|
383
|
+
if self.use_colors is not None:
|
|
384
|
+
# App(use_colors=...) is the default for every piece on screen
|
|
385
|
+
# (widgets and components alike) that did not choose for itself.
|
|
386
|
+
for node in self.walk():
|
|
387
|
+
if node is not self and \
|
|
388
|
+
getattr(node, 'use_colors', False) is None:
|
|
389
|
+
node.use_colors = self.use_colors
|
|
390
|
+
lines = self._compose(width, height)
|
|
391
|
+
candidates = self.focusables()
|
|
392
|
+
if self.focused not in candidates:
|
|
393
|
+
# First frame, or the focused component left the screen (a
|
|
394
|
+
# narrower layout hid it, a dialog closed): focus the first one.
|
|
395
|
+
self.focus(candidates[0] if candidates else None)
|
|
396
|
+
if self.focused is not None:
|
|
397
|
+
lines = self._compose(width, height)
|
|
398
|
+
self.last_frame = '\n'.join(lines)
|
|
399
|
+
self.frames += 1
|
|
400
|
+
self._dirty = False
|
|
401
|
+
return self.last_frame
|
|
402
|
+
|
|
403
|
+
# -- input --------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def dispatch(self, key):
|
|
406
|
+
"""Route one key; returns True when something used it."""
|
|
407
|
+
if not isinstance(key, Key):
|
|
408
|
+
key = Key(str(key), str(key) if len(str(key)) == 1 else '')
|
|
409
|
+
self._dirty = True
|
|
410
|
+
if str(key) == 'ctrl+c' and 'ctrl+c' in self.bindings:
|
|
411
|
+
self.bindings['ctrl+c']()
|
|
412
|
+
return True
|
|
413
|
+
node = self.focused
|
|
414
|
+
seen = set()
|
|
415
|
+
while node is not None and id(node) not in seen:
|
|
416
|
+
seen.add(id(node))
|
|
417
|
+
if isinstance(node, Component) and node.handle_key(key):
|
|
418
|
+
return True
|
|
419
|
+
node = getattr(node, 'parent', None)
|
|
420
|
+
top = self._modal()
|
|
421
|
+
if top is None and self.focused is None:
|
|
422
|
+
# Nothing has the focus: the screen's own components still get
|
|
423
|
+
# their keys (a shell's shortcuts, a page with no inputs).
|
|
424
|
+
top = self.screen.body
|
|
425
|
+
if isinstance(top, Component) and top is not self.focused and \
|
|
426
|
+
id(top) not in seen and top.handle_key(key):
|
|
427
|
+
return True
|
|
428
|
+
action = self.bindings.get(str(key))
|
|
429
|
+
if action is not None:
|
|
430
|
+
result = action()
|
|
431
|
+
return result is not False
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
def exit(self, result=None):
|
|
435
|
+
"""Stop the loop; ``run()`` returns ``result``."""
|
|
436
|
+
self.result = result
|
|
437
|
+
self.running = False
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
quit = exit
|
|
441
|
+
|
|
442
|
+
# -- loop ---------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def run(self, keys=None, size=None, max_frames=None):
|
|
445
|
+
"""
|
|
446
|
+
Run until ``exit()``. With ``keys`` (an iterable of key names or
|
|
447
|
+
:class:`Key`), run headless: no terminal, the keys fed one by one,
|
|
448
|
+
the frame redrawn after each; returns the last frame. Otherwise
|
|
449
|
+
returns ``result`` passed to ``exit()``.
|
|
450
|
+
"""
|
|
451
|
+
if keys is not None:
|
|
452
|
+
return self._run_headless(keys, size)
|
|
453
|
+
return self._run_terminal(size, max_frames)
|
|
454
|
+
|
|
455
|
+
def _run_headless(self, keys, size):
|
|
456
|
+
width, height = size or (80, 24)
|
|
457
|
+
stream = self.stream if self.stream is not None else io.StringIO()
|
|
458
|
+
live = Live(self, stream=stream, force_terminal=True, width=width,
|
|
459
|
+
height=height)
|
|
460
|
+
self.running = True
|
|
461
|
+
with live:
|
|
462
|
+
live.update()
|
|
463
|
+
for key in keys:
|
|
464
|
+
if not self.running:
|
|
465
|
+
break
|
|
466
|
+
if str(key) == 'tick':
|
|
467
|
+
if self.on_tick is not None:
|
|
468
|
+
self.on_tick(self)
|
|
469
|
+
self._dirty = True
|
|
470
|
+
else:
|
|
471
|
+
self.dispatch(key)
|
|
472
|
+
if self._dirty:
|
|
473
|
+
live.update()
|
|
474
|
+
self.running = False
|
|
475
|
+
self.live = live
|
|
476
|
+
return self.last_frame
|
|
477
|
+
|
|
478
|
+
def _run_terminal(self, size, max_frames): # pragma: no cover - needs a tty
|
|
479
|
+
from .terminal import Terminal
|
|
480
|
+
terminal = self.terminal or Terminal(mouse=self.mouse)
|
|
481
|
+
width, height = size or (None, None)
|
|
482
|
+
live = Live(self, stream=self.stream, width=width, height=height)
|
|
483
|
+
self.running = True
|
|
484
|
+
next_tick = time.monotonic() + (self.interval or 0)
|
|
485
|
+
with terminal, live:
|
|
486
|
+
live.update()
|
|
487
|
+
while self.running:
|
|
488
|
+
timeout = 0.1
|
|
489
|
+
if self.interval:
|
|
490
|
+
timeout = max(0.0, min(timeout,
|
|
491
|
+
next_tick - time.monotonic()))
|
|
492
|
+
for key in terminal.read_keys(timeout=timeout):
|
|
493
|
+
self.dispatch(key)
|
|
494
|
+
if not self.running:
|
|
495
|
+
break
|
|
496
|
+
if any(isinstance(l, _Toast) and l.until <= self.clock()
|
|
497
|
+
for l in self.layers):
|
|
498
|
+
self._dirty = True
|
|
499
|
+
if self.interval and time.monotonic() >= next_tick:
|
|
500
|
+
next_tick += self.interval
|
|
501
|
+
if self.on_tick is not None:
|
|
502
|
+
self.on_tick(self)
|
|
503
|
+
self._dirty = True
|
|
504
|
+
if terminal.resized():
|
|
505
|
+
live.invalidate()
|
|
506
|
+
self._dirty = True
|
|
507
|
+
if self._dirty and self.running:
|
|
508
|
+
live.update()
|
|
509
|
+
if max_frames is not None and self.frames >= max_frames:
|
|
510
|
+
break
|
|
511
|
+
self.live = live
|
|
512
|
+
return self.result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class _Toast(Component):
|
|
516
|
+
"""A notification drawn by :meth:`App.notify`."""
|
|
517
|
+
|
|
518
|
+
COLORS = {'info': 'blue', 'success': 'green', 'warning': 'yellow',
|
|
519
|
+
'error': 'red'}
|
|
520
|
+
ICONS = {'info': 'ℹ', 'success': '✔', 'warning': '⚠', 'error': '✖'}
|
|
521
|
+
|
|
522
|
+
def __init__(self, message, kind, until):
|
|
523
|
+
super(_Toast, self).__init__()
|
|
524
|
+
self.message = str(message)
|
|
525
|
+
self.kind = kind
|
|
526
|
+
self.until = until
|
|
527
|
+
|
|
528
|
+
def natural_width(self):
|
|
529
|
+
return min(48, _fx.visible_width(self.message) + 6)
|
|
530
|
+
|
|
531
|
+
def draw(self, width, height):
|
|
532
|
+
color = self.COLORS.get(self.kind, 'blue')
|
|
533
|
+
inner = max(1, width - 2)
|
|
534
|
+
text = _fx.fit_line(self.ICONS.get(self.kind, '•') + ' ' +
|
|
535
|
+
self.message, inner)
|
|
536
|
+
if not self._active():
|
|
537
|
+
return ['+' + '-' * inner + '+', '|' + text + '|',
|
|
538
|
+
'+' + '-' * inner + '+']
|
|
539
|
+
return [self.paint(' ' + text + ' ', 'bold black on ' + color)]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class ScrollView(Component):
|
|
543
|
+
"""
|
|
544
|
+
A scrollable viewport.
|
|
545
|
+
|
|
546
|
+
``content`` is text, a list of lines, or any renderable (drawn at the
|
|
547
|
+
view's width minus the scroll bar). ``height`` fixes the view's height;
|
|
548
|
+
otherwise it takes the height its slot gives it. ``follow=True`` keeps
|
|
549
|
+
the view at the bottom while it is there (for logs).
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
focusable = True
|
|
553
|
+
expand = True
|
|
554
|
+
|
|
555
|
+
def __init__(self, content='', height=None, follow=False, scrollbar=True,
|
|
556
|
+
color='bright_black', focus_color='cyan', step=3, **kwargs):
|
|
557
|
+
super(ScrollView, self).__init__(**kwargs)
|
|
558
|
+
self.content = content
|
|
559
|
+
self.height = None if height is None else max(1, int(height))
|
|
560
|
+
self.follow = bool(follow)
|
|
561
|
+
self.scrollbar = bool(scrollbar)
|
|
562
|
+
self.color = color
|
|
563
|
+
self.focus_color = focus_color
|
|
564
|
+
self.step = max(1, int(step))
|
|
565
|
+
self.offset = 0
|
|
566
|
+
self.view_height = self.height or 1
|
|
567
|
+
self.total = 0
|
|
568
|
+
|
|
569
|
+
def natural_width(self):
|
|
570
|
+
from flexablelib.layout import _natural_width
|
|
571
|
+
return _natural_width(self.content) + (1 if self.scrollbar else 0)
|
|
572
|
+
|
|
573
|
+
def children(self):
|
|
574
|
+
return [self.content] if not isinstance(self.content, (str, list)) \
|
|
575
|
+
else []
|
|
576
|
+
|
|
577
|
+
def _lines(self, width):
|
|
578
|
+
content = self.content
|
|
579
|
+
if isinstance(content, list):
|
|
580
|
+
return [str(line) for line in content]
|
|
581
|
+
if isinstance(content, str):
|
|
582
|
+
return content.split('\n') if content else []
|
|
583
|
+
text = _render_child(content, width)
|
|
584
|
+
return text.split('\n') if text else []
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def max_offset(self):
|
|
588
|
+
return max(0, self.total - self.view_height)
|
|
589
|
+
|
|
590
|
+
def scroll_to(self, offset):
|
|
591
|
+
self.offset = max(0, min(int(offset), self.max_offset))
|
|
592
|
+
self.refresh()
|
|
593
|
+
|
|
594
|
+
def scroll(self, delta):
|
|
595
|
+
self.scroll_to(self.offset + delta)
|
|
596
|
+
|
|
597
|
+
def draw(self, width, height):
|
|
598
|
+
h = self.height or height or self.view_height
|
|
599
|
+
inner = max(1, width - 1) if self.scrollbar else width
|
|
600
|
+
lines = self._lines(inner)
|
|
601
|
+
at_bottom = self.offset >= self.max_offset
|
|
602
|
+
self.total = len(lines)
|
|
603
|
+
self.view_height = max(1, h)
|
|
604
|
+
if self.follow and at_bottom:
|
|
605
|
+
self.offset = self.max_offset
|
|
606
|
+
self.offset = max(0, min(self.offset, self.max_offset))
|
|
607
|
+
visible = lines[self.offset:self.offset + self.view_height]
|
|
608
|
+
visible += [''] * (self.view_height - len(visible))
|
|
609
|
+
if not self.scrollbar:
|
|
610
|
+
return visible
|
|
611
|
+
bar = self._bar()
|
|
612
|
+
return [_fx.fit_line(line, inner) + mark
|
|
613
|
+
for line, mark in zip(visible, bar)]
|
|
614
|
+
|
|
615
|
+
def _bar(self):
|
|
616
|
+
h = self.view_height
|
|
617
|
+
color = self.focus_color if self.focused else self.color
|
|
618
|
+
if self.total <= h:
|
|
619
|
+
return [' '] * h
|
|
620
|
+
thumb = max(1, h * h // self.total)
|
|
621
|
+
top = (h - thumb) * self.offset // max(1, self.max_offset)
|
|
622
|
+
return [self.paint('┃' if top <= k < top + thumb else '│', color)
|
|
623
|
+
for k in range(h)]
|
|
624
|
+
|
|
625
|
+
def on_key(self, key):
|
|
626
|
+
k = str(key)
|
|
627
|
+
moves = {'up': -1, 'k': -1, 'down': 1, 'j': 1,
|
|
628
|
+
'scrollup': -self.step, 'scrolldown': self.step,
|
|
629
|
+
'pageup': -self.view_height, 'pagedown': self.view_height,
|
|
630
|
+
'space': self.view_height}
|
|
631
|
+
if k in moves:
|
|
632
|
+
self.scroll(moves[k])
|
|
633
|
+
return True
|
|
634
|
+
if k in ('home', 'g'):
|
|
635
|
+
self.scroll_to(0)
|
|
636
|
+
return True
|
|
637
|
+
if k in ('end', 'G'):
|
|
638
|
+
self.scroll_to(self.max_offset)
|
|
639
|
+
return True
|
|
640
|
+
return False
|