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
flexablelib_tui/keys.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KEYBOARD INPUT
|
|
3
|
+
|
|
4
|
+
Terminals report keys as bytes: printable text as itself, control keys as
|
|
5
|
+
control characters, and everything else (arrows, function keys, modifiers,
|
|
6
|
+
mouse, paste) as escape sequences that differ between terminals. On Windows
|
|
7
|
+
the console reports special keys as a ``'\\x00'`` or ``'\\xe0'`` prefix and a
|
|
8
|
+
scan code instead.
|
|
9
|
+
|
|
10
|
+
:class:`KeyParser` (escape sequences) and :class:`WindowsKeyParser` (console
|
|
11
|
+
scan codes) turn either into :class:`Key` values with one naming scheme:
|
|
12
|
+
|
|
13
|
+
- printable characters are themselves: ``'a'``, ``'A'``, ``'ñ'``, ``'?'``;
|
|
14
|
+
- named keys: ``'enter'``, ``'tab'``, ``'shift+tab'``, ``'backspace'``,
|
|
15
|
+
``'escape'``, ``'space'``, ``'up'``, ``'down'``, ``'left'``, ``'right'``,
|
|
16
|
+
``'home'``, ``'end'``, ``'pageup'``, ``'pagedown'``, ``'insert'``,
|
|
17
|
+
``'delete'``, ``'f1'`` ... ``'f12'``;
|
|
18
|
+
- modifiers in the order ``ctrl+alt+shift+``: ``'ctrl+c'``, ``'alt+x'``,
|
|
19
|
+
``'ctrl+up'``, ``'shift+right'``;
|
|
20
|
+
- ``'paste'`` (bracketed paste; the text is in ``key.char``) and ``'mouse'``
|
|
21
|
+
(``key.mouse`` is ``(button, x, y, pressed)``, 0-based cells; the wheel is
|
|
22
|
+
reported as the keys ``'scrollup'`` / ``'scrolldown'``).
|
|
23
|
+
|
|
24
|
+
A :class:`Key` is a ``str`` (its name), so ``key == 'ctrl+s'`` works and keys
|
|
25
|
+
can be dictionary keys of key bindings.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__all__ = ['Key', 'KeyParser', 'WindowsKeyParser', 'parse_keys']
|
|
29
|
+
|
|
30
|
+
ESC = '\x1b'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Key(str):
|
|
34
|
+
"""A key press: the string is its name, ``char`` the text it types."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ('char', 'mouse')
|
|
37
|
+
|
|
38
|
+
def __new__(cls, name, char='', mouse=None):
|
|
39
|
+
self = str.__new__(cls, name)
|
|
40
|
+
self.char = char
|
|
41
|
+
self.mouse = mouse
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self):
|
|
46
|
+
return str(self)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_printable(self):
|
|
50
|
+
"""Whether the key types text (``char`` is that text)."""
|
|
51
|
+
return bool(self.char) and str(self) not in ('enter', 'tab', 'paste')
|
|
52
|
+
|
|
53
|
+
def __repr__(self):
|
|
54
|
+
return 'Key({0!r})'.format(str(self))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_CSI_TILDE = {
|
|
58
|
+
1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'pageup', 6: 'pagedown',
|
|
59
|
+
7: 'home', 8: 'end', 11: 'f1', 12: 'f2', 13: 'f3', 14: 'f4', 15: 'f5',
|
|
60
|
+
17: 'f6', 18: 'f7', 19: 'f8', 20: 'f9', 21: 'f10', 23: 'f11', 24: 'f12',
|
|
61
|
+
}
|
|
62
|
+
_CSI_LETTER = {
|
|
63
|
+
'A': 'up', 'B': 'down', 'C': 'right', 'D': 'left', 'H': 'home',
|
|
64
|
+
'F': 'end', 'P': 'f1', 'Q': 'f2', 'R': 'f3', 'S': 'f4', 'E': 'begin',
|
|
65
|
+
}
|
|
66
|
+
_SS3 = dict(_CSI_LETTER)
|
|
67
|
+
_SS3.update({'M': 'enter', 'j': '*', 'k': '+', 'm': '-', 'n': '.', 'o': '/'})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _modified(name, code):
|
|
71
|
+
"""Apply an xterm modifier parameter (1 + shift|alt<<1|ctrl<<2)."""
|
|
72
|
+
bits = max(0, code - 1)
|
|
73
|
+
prefix = ''
|
|
74
|
+
if bits & 4:
|
|
75
|
+
prefix += 'ctrl+'
|
|
76
|
+
if bits & 2:
|
|
77
|
+
prefix += 'alt+'
|
|
78
|
+
if bits & 1:
|
|
79
|
+
prefix += 'shift+'
|
|
80
|
+
return prefix + name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _control(ch):
|
|
84
|
+
"""The key for one character outside an escape sequence."""
|
|
85
|
+
code = ord(ch)
|
|
86
|
+
if ch in '\r\n':
|
|
87
|
+
return Key('enter', '\n')
|
|
88
|
+
if ch == '\t':
|
|
89
|
+
return Key('tab', '\t')
|
|
90
|
+
if ch in '\x7f\x08':
|
|
91
|
+
return Key('backspace')
|
|
92
|
+
if ch == ' ':
|
|
93
|
+
return Key('space', ' ')
|
|
94
|
+
if code == 0:
|
|
95
|
+
return Key('ctrl+space')
|
|
96
|
+
if code < 27:
|
|
97
|
+
return Key('ctrl+' + chr(code + 96))
|
|
98
|
+
if code < 32:
|
|
99
|
+
return Key('ctrl+' + '\\]^_'[code - 28])
|
|
100
|
+
return Key(ch, ch)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class KeyParser(object):
|
|
104
|
+
"""
|
|
105
|
+
Incremental parser for terminal key input.
|
|
106
|
+
|
|
107
|
+
>>> parser = KeyParser()
|
|
108
|
+
>>> parser.feed('\\x1b[A')
|
|
109
|
+
[Key('up')]
|
|
110
|
+
>>> parser.feed('\\x1b') # could be the start of a sequence ...
|
|
111
|
+
[]
|
|
112
|
+
>>> parser.flush() # ... until no more input comes
|
|
113
|
+
[Key('escape')]
|
|
114
|
+
|
|
115
|
+
``feed`` accepts ``str`` or UTF-8 ``bytes`` (split multi-byte characters
|
|
116
|
+
are held back until complete).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self):
|
|
120
|
+
self._buffer = ''
|
|
121
|
+
self._bytes = b''
|
|
122
|
+
self._paste = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def pending(self):
|
|
126
|
+
"""Whether input is held back waiting for the rest of a sequence."""
|
|
127
|
+
return bool(self._buffer or self._bytes or self._paste is not None)
|
|
128
|
+
|
|
129
|
+
def _decode(self, data):
|
|
130
|
+
if isinstance(data, str):
|
|
131
|
+
return data
|
|
132
|
+
data = self._bytes + data
|
|
133
|
+
for cut in range(len(data), max(-1, len(data) - 4), -1):
|
|
134
|
+
try:
|
|
135
|
+
text = data[:cut].decode('utf-8')
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
continue
|
|
138
|
+
self._bytes = data[cut:]
|
|
139
|
+
return text
|
|
140
|
+
self._bytes = b''
|
|
141
|
+
return data.decode('utf-8', 'replace')
|
|
142
|
+
|
|
143
|
+
def feed(self, data):
|
|
144
|
+
"""Parse ``data``; returns the complete keys found."""
|
|
145
|
+
self._buffer += self._decode(data)
|
|
146
|
+
keys = []
|
|
147
|
+
buf = self._buffer
|
|
148
|
+
i = 0
|
|
149
|
+
n = len(buf)
|
|
150
|
+
while i < n:
|
|
151
|
+
if self._paste is not None:
|
|
152
|
+
end = buf.find(ESC + '[201~', i)
|
|
153
|
+
if end < 0:
|
|
154
|
+
self._paste += buf[i:]
|
|
155
|
+
i = n
|
|
156
|
+
break
|
|
157
|
+
self._paste += buf[i:end]
|
|
158
|
+
keys.append(Key('paste', self._paste))
|
|
159
|
+
self._paste = None
|
|
160
|
+
i = end + 6
|
|
161
|
+
continue
|
|
162
|
+
ch = buf[i]
|
|
163
|
+
if ch != ESC:
|
|
164
|
+
keys.append(_control(ch))
|
|
165
|
+
i += 1
|
|
166
|
+
continue
|
|
167
|
+
consumed, key = self._escape(buf, i)
|
|
168
|
+
if consumed == 0: # incomplete: wait for more input
|
|
169
|
+
break
|
|
170
|
+
if key is not None:
|
|
171
|
+
keys.append(key)
|
|
172
|
+
i += consumed
|
|
173
|
+
self._buffer = buf[i:]
|
|
174
|
+
return keys
|
|
175
|
+
|
|
176
|
+
def flush(self):
|
|
177
|
+
"""Resolve held-back input when no more is coming (a lone Escape)."""
|
|
178
|
+
keys = []
|
|
179
|
+
if self._paste is not None:
|
|
180
|
+
keys.append(Key('paste', self._paste))
|
|
181
|
+
self._paste = None
|
|
182
|
+
buf, self._buffer = self._buffer, ''
|
|
183
|
+
if buf == ESC:
|
|
184
|
+
keys.append(Key('escape'))
|
|
185
|
+
elif buf.startswith(ESC + ESC):
|
|
186
|
+
keys.append(Key('escape'))
|
|
187
|
+
keys.extend(self.feed(buf[1:]))
|
|
188
|
+
keys.extend(self.flush())
|
|
189
|
+
elif buf.startswith(ESC) and len(buf) == 2:
|
|
190
|
+
keys.append(self._alt(buf[1]))
|
|
191
|
+
elif buf:
|
|
192
|
+
# A truncated sequence: report its characters as typed.
|
|
193
|
+
keys.append(Key('escape'))
|
|
194
|
+
keys.extend(_control(c) for c in buf[1:])
|
|
195
|
+
return keys
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _alt(ch):
|
|
199
|
+
key = _control(ch)
|
|
200
|
+
if str(key).startswith('ctrl+'):
|
|
201
|
+
return Key('ctrl+alt+' + str(key)[5:])
|
|
202
|
+
return Key('alt+' + str(key), '')
|
|
203
|
+
|
|
204
|
+
def _escape(self, buf, i):
|
|
205
|
+
"""``(consumed, key)``; ``consumed == 0`` means incomplete."""
|
|
206
|
+
n = len(buf)
|
|
207
|
+
if i + 1 >= n:
|
|
208
|
+
return 0, None
|
|
209
|
+
nxt = buf[i + 1]
|
|
210
|
+
if nxt == '[':
|
|
211
|
+
return self._csi(buf, i)
|
|
212
|
+
if nxt == 'O':
|
|
213
|
+
if i + 2 >= n:
|
|
214
|
+
return 0, None
|
|
215
|
+
name = _SS3.get(buf[i + 2])
|
|
216
|
+
if name is None:
|
|
217
|
+
return 3, None
|
|
218
|
+
return 3, Key(name, '\n' if name == 'enter' else '')
|
|
219
|
+
if nxt == ESC:
|
|
220
|
+
# ESC ESC: an Escape press followed by whatever comes next.
|
|
221
|
+
return 1, Key('escape')
|
|
222
|
+
return 2, self._alt(nxt)
|
|
223
|
+
|
|
224
|
+
def _csi(self, buf, i):
|
|
225
|
+
n = len(buf)
|
|
226
|
+
j = i + 2
|
|
227
|
+
while j < n and '\x30' <= buf[j] <= '\x3f':
|
|
228
|
+
j += 1
|
|
229
|
+
while j < n and '\x20' <= buf[j] <= '\x2f':
|
|
230
|
+
j += 1
|
|
231
|
+
if j >= n:
|
|
232
|
+
return 0, None
|
|
233
|
+
final = buf[j]
|
|
234
|
+
params = buf[i + 2:j]
|
|
235
|
+
consumed = j - i + 1
|
|
236
|
+
if params.startswith('<') and final in 'Mm':
|
|
237
|
+
return consumed, self._mouse(params[1:], final == 'M')
|
|
238
|
+
nums = []
|
|
239
|
+
for part in params.split(';'):
|
|
240
|
+
try:
|
|
241
|
+
nums.append(int(part) if part else 1)
|
|
242
|
+
except ValueError:
|
|
243
|
+
return consumed, None
|
|
244
|
+
if final == '~':
|
|
245
|
+
code = nums[0] if nums else 1
|
|
246
|
+
if code == 200:
|
|
247
|
+
self._paste = ''
|
|
248
|
+
return consumed, None
|
|
249
|
+
name = _CSI_TILDE.get(code)
|
|
250
|
+
if name is None:
|
|
251
|
+
return consumed, None
|
|
252
|
+
if len(nums) > 1:
|
|
253
|
+
name = _modified(name, nums[1])
|
|
254
|
+
return consumed, Key(name)
|
|
255
|
+
if final == 'Z':
|
|
256
|
+
return consumed, Key('shift+tab')
|
|
257
|
+
if final == 'I':
|
|
258
|
+
return consumed, Key('focusin')
|
|
259
|
+
if final == 'O' and not params:
|
|
260
|
+
return consumed, Key('focusout')
|
|
261
|
+
name = _CSI_LETTER.get(final)
|
|
262
|
+
if name is None:
|
|
263
|
+
return consumed, None
|
|
264
|
+
if len(nums) > 1:
|
|
265
|
+
name = _modified(name, nums[1])
|
|
266
|
+
return consumed, Key(name)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _mouse(params, pressed):
|
|
270
|
+
try:
|
|
271
|
+
button, x, y = (int(p) for p in params.split(';'))
|
|
272
|
+
except ValueError:
|
|
273
|
+
return None
|
|
274
|
+
if button & 64:
|
|
275
|
+
return Key('scrolldown' if button & 1 else 'scrollup',
|
|
276
|
+
mouse=(button, x - 1, y - 1, pressed))
|
|
277
|
+
return Key('mouse', mouse=(button & 3, x - 1, y - 1, pressed))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Windows console: the scan code after a '\x00' / '\xe0' prefix.
|
|
281
|
+
_WIN_SCAN = {
|
|
282
|
+
'H': 'up', 'P': 'down', 'K': 'left', 'M': 'right', 'G': 'home',
|
|
283
|
+
'O': 'end', 'I': 'pageup', 'Q': 'pagedown', 'R': 'insert', 'S': 'delete',
|
|
284
|
+
';': 'f1', '<': 'f2', '=': 'f3', '>': 'f4', '?': 'f5', '@': 'f6',
|
|
285
|
+
'A': 'f7', 'B': 'f8', 'C': 'f9', 'D': 'f10', '\x85': 'f11',
|
|
286
|
+
'\x86': 'f12', 's': 'ctrl+left', 't': 'ctrl+right', '\x8d': 'ctrl+up',
|
|
287
|
+
'\x91': 'ctrl+down', 'w': 'ctrl+home', 'u': 'ctrl+end',
|
|
288
|
+
'\x84': 'ctrl+pageup', 'v': 'ctrl+pagedown', '\x93': 'ctrl+delete',
|
|
289
|
+
'\x92': 'ctrl+insert', '\x94': 'ctrl+tab',
|
|
290
|
+
'T': 'shift+f1', 'U': 'shift+f2', 'V': 'shift+f3', 'W': 'shift+f4',
|
|
291
|
+
'X': 'shift+f5', 'Y': 'shift+f6', 'Z': 'shift+f7', '[': 'shift+f8',
|
|
292
|
+
'\\': 'shift+f9', ']': 'shift+f10', '^': 'ctrl+f1', '_': 'ctrl+f2',
|
|
293
|
+
'`': 'ctrl+f3', 'a': 'ctrl+f4', 'b': 'ctrl+f5', 'h': 'alt+f1',
|
|
294
|
+
'k': 'alt+f4',
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class WindowsKeyParser(object):
|
|
299
|
+
"""
|
|
300
|
+
Parser for characters read with ``msvcrt.getwch()``.
|
|
301
|
+
|
|
302
|
+
Special keys arrive as ``'\\x00'`` or ``'\\xe0'`` followed by a scan code;
|
|
303
|
+
Windows Terminal with virtual-terminal input sends escape sequences
|
|
304
|
+
instead, which are handed to a :class:`KeyParser`.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def __init__(self):
|
|
308
|
+
self._prefix = False
|
|
309
|
+
self._vt = KeyParser()
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def pending(self):
|
|
313
|
+
return self._prefix or self._vt.pending
|
|
314
|
+
|
|
315
|
+
def feed(self, chars):
|
|
316
|
+
keys = []
|
|
317
|
+
for ch in chars:
|
|
318
|
+
if self._prefix:
|
|
319
|
+
self._prefix = False
|
|
320
|
+
name = _WIN_SCAN.get(ch)
|
|
321
|
+
if name is not None:
|
|
322
|
+
keys.append(Key(name))
|
|
323
|
+
continue
|
|
324
|
+
if ch in '\x00\xe0' and not self._vt.pending:
|
|
325
|
+
self._prefix = True
|
|
326
|
+
continue
|
|
327
|
+
if ch == '\r':
|
|
328
|
+
ch = '\n'
|
|
329
|
+
keys.extend(self._vt.feed(ch))
|
|
330
|
+
return keys
|
|
331
|
+
|
|
332
|
+
def flush(self):
|
|
333
|
+
self._prefix = False
|
|
334
|
+
return self._vt.flush()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def parse_keys(data):
|
|
338
|
+
"""Every key in ``data`` (a complete input string or bytes)."""
|
|
339
|
+
parser = KeyParser()
|
|
340
|
+
return parser.feed(data) + parser.flush()
|
flexablelib_tui/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FULL-SCREEN LAYOUT AND LIVE REFRESH
|
|
3
|
+
|
|
4
|
+
``Screen``
|
|
5
|
+
A header, a body and a footer laid out to the exact terminal size: the
|
|
6
|
+
header sticks to the top, the footer to the bottom, and the body gets
|
|
7
|
+
every line in between (a :class:`~flexablelib.Grid` body hands them to
|
|
8
|
+
its fraction rows, a :class:`~flexablelib.widgets.LogView` shows that many
|
|
9
|
+
lines). Content taller than the screen is cut, so the footer never scrolls
|
|
10
|
+
away.
|
|
11
|
+
|
|
12
|
+
``Live``
|
|
13
|
+
Redraws a renderable in place: ``with Live(screen) as live: ...
|
|
14
|
+
live.update()``. On a terminal it uses the alternate screen and hides the
|
|
15
|
+
cursor, restoring both on exit; on a pipe or file it writes each frame
|
|
16
|
+
once, plainly. After the first frame only the lines that changed are
|
|
17
|
+
written (``diff=True``), and a change of terminal size redraws in full.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from flexablelib import _frames as _fx
|
|
24
|
+
from flexablelib.layout import (_accepts_height, _render_child, _fit_line,
|
|
25
|
+
render_width)
|
|
26
|
+
from flexablelib.utils import get_window_size
|
|
27
|
+
|
|
28
|
+
__all__ = ['Screen', 'Live', 'render_height']
|
|
29
|
+
|
|
30
|
+
ALT_SCREEN_ON = '\x1b[?1049h'
|
|
31
|
+
ALT_SCREEN_OFF = '\x1b[?1049l'
|
|
32
|
+
CURSOR_HIDE = '\x1b[?25l'
|
|
33
|
+
CURSOR_SHOW = '\x1b[?25h'
|
|
34
|
+
HOME = '\x1b[H'
|
|
35
|
+
CLEAR_LINE = '\x1b[K'
|
|
36
|
+
CLEAR_BELOW = '\x1b[J'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_height(height=None):
|
|
40
|
+
"""The height to lay out into: ``height`` if given, else the terminal's."""
|
|
41
|
+
if height is not None:
|
|
42
|
+
height = int(height)
|
|
43
|
+
if height < 1:
|
|
44
|
+
raise ValueError('height must be >= 1, got {0}'.format(height))
|
|
45
|
+
return height
|
|
46
|
+
_, lines = get_window_size()
|
|
47
|
+
return max(1, int(lines))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render(child, width, height=None):
|
|
51
|
+
if child is None:
|
|
52
|
+
return []
|
|
53
|
+
if height is not None and _accepts_height(child):
|
|
54
|
+
text = child.render(width=width, height=height)
|
|
55
|
+
else:
|
|
56
|
+
text = _render_child(child, width)
|
|
57
|
+
return text.split('\n') if text != '' else []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Screen(object):
|
|
61
|
+
"""
|
|
62
|
+
A whole terminal screen: ``header`` on top, ``footer`` at the bottom,
|
|
63
|
+
``body`` filling the rest.
|
|
64
|
+
|
|
65
|
+
>>> screen = Screen(body=grid, header=StatusBar('App', right='12:00'),
|
|
66
|
+
... footer=KeyHints({'q': 'Salir'}))
|
|
67
|
+
>>> print(screen.render(width=100, height=30)) # exactly 30 lines
|
|
68
|
+
|
|
69
|
+
``width`` / ``height`` fix the size (default: the terminal's).
|
|
70
|
+
``height=None`` *and* ``fit_height=False`` let the body take its natural
|
|
71
|
+
height (the footer then follows it instead of sticking to the bottom).
|
|
72
|
+
``margin`` is blank columns on each side.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, body=None, header=None, footer=None, width=None,
|
|
76
|
+
height=None, fit_height=True, margin=0):
|
|
77
|
+
self.body = body
|
|
78
|
+
self.header = header
|
|
79
|
+
self.footer = footer
|
|
80
|
+
self.width = width
|
|
81
|
+
self.height = height
|
|
82
|
+
self.fit_height = bool(fit_height)
|
|
83
|
+
self.margin = max(0, int(margin))
|
|
84
|
+
|
|
85
|
+
def natural_width(self):
|
|
86
|
+
from flexablelib.layout import _natural_width
|
|
87
|
+
return max(_natural_width(self.body), _natural_width(self.header),
|
|
88
|
+
_natural_width(self.footer)) + 2 * self.margin
|
|
89
|
+
|
|
90
|
+
def __str__(self):
|
|
91
|
+
return self.render()
|
|
92
|
+
|
|
93
|
+
def render(self, width=None, height=None):
|
|
94
|
+
width = render_width(width if width is not None else self.width)
|
|
95
|
+
if height is None:
|
|
96
|
+
height = self.height
|
|
97
|
+
if height is None and self.fit_height:
|
|
98
|
+
height = render_height(None)
|
|
99
|
+
inner = max(1, width - 2 * self.margin)
|
|
100
|
+
head = _render(self.header, inner)
|
|
101
|
+
foot = _render(self.footer, inner)
|
|
102
|
+
if height is None:
|
|
103
|
+
body = _render(self.body, inner)
|
|
104
|
+
else:
|
|
105
|
+
height = int(height)
|
|
106
|
+
room = height - len(head) - len(foot)
|
|
107
|
+
if room < 0:
|
|
108
|
+
# The footer outranks the header: drop header lines first.
|
|
109
|
+
head = head[:max(0, height - len(foot))]
|
|
110
|
+
foot = foot[-height:] if len(foot) > height else foot
|
|
111
|
+
room = height - len(head) - len(foot)
|
|
112
|
+
body = _render(self.body, inner, room) if room > 0 else []
|
|
113
|
+
body = body[:room] + [''] * (room - len(body[:room]))
|
|
114
|
+
side = ' ' * self.margin
|
|
115
|
+
lines = [side + _fit_line(line, inner) + side
|
|
116
|
+
for line in head + body + foot]
|
|
117
|
+
return '\n'.join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Live(object):
|
|
121
|
+
"""
|
|
122
|
+
Redraw a renderable in place.
|
|
123
|
+
|
|
124
|
+
>>> with Live(screen) as live:
|
|
125
|
+
... for tick in range(10):
|
|
126
|
+
... update_data(tick)
|
|
127
|
+
... live.update()
|
|
128
|
+
... time.sleep(1)
|
|
129
|
+
|
|
130
|
+
``renderable`` is anything with ``render(width, height)`` (or a string);
|
|
131
|
+
``update(new)`` swaps it. ``screen=True`` uses the alternate screen so the
|
|
132
|
+
shell comes back untouched; ``transient=False`` reprints the last frame
|
|
133
|
+
on the normal screen at exit. ``stream`` defaults to ``sys.stdout``;
|
|
134
|
+
``force_terminal`` overrides the ``isatty()`` check.
|
|
135
|
+
|
|
136
|
+
``diff=True`` (the default) writes only the lines that changed since the
|
|
137
|
+
previous frame -- each one a cursor move, the line and a clear to its
|
|
138
|
+
end -- so a clock ticking in a corner costs a few dozen bytes, not the
|
|
139
|
+
whole screen. ``diff=False`` repaints every frame. ``bytes_written``
|
|
140
|
+
counts what went to the stream.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, renderable=None, stream=None, screen=True,
|
|
144
|
+
hide_cursor=True, transient=False, force_terminal=None,
|
|
145
|
+
width=None, height=None, diff=True):
|
|
146
|
+
self.renderable = renderable
|
|
147
|
+
self.diff = bool(diff)
|
|
148
|
+
self.bytes_written = 0
|
|
149
|
+
self._lines = None
|
|
150
|
+
self._size = None
|
|
151
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
152
|
+
self.screen = screen
|
|
153
|
+
self.hide_cursor = hide_cursor
|
|
154
|
+
self.transient = transient
|
|
155
|
+
self.force_terminal = force_terminal
|
|
156
|
+
self.width = width
|
|
157
|
+
self.height = height
|
|
158
|
+
self.frames = 0
|
|
159
|
+
self._last = None
|
|
160
|
+
self._started = False
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_terminal(self):
|
|
164
|
+
if self.force_terminal is not None:
|
|
165
|
+
return bool(self.force_terminal)
|
|
166
|
+
try:
|
|
167
|
+
return bool(self.stream.isatty())
|
|
168
|
+
except (AttributeError, ValueError):
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def _frame(self):
|
|
172
|
+
r = self.renderable
|
|
173
|
+
if r is None:
|
|
174
|
+
return ''
|
|
175
|
+
width = render_width(self.width)
|
|
176
|
+
height = self.height
|
|
177
|
+
if height is None and self.is_terminal:
|
|
178
|
+
height = render_height(None)
|
|
179
|
+
if isinstance(r, str):
|
|
180
|
+
lines = r.split('\n')
|
|
181
|
+
if height is not None:
|
|
182
|
+
lines = lines[:height]
|
|
183
|
+
return '\n'.join(lines)
|
|
184
|
+
if _accepts_height(r):
|
|
185
|
+
return r.render(width=width, height=height)
|
|
186
|
+
return _render_child(r, width)
|
|
187
|
+
|
|
188
|
+
def _write(self, text):
|
|
189
|
+
self.bytes_written += len(text)
|
|
190
|
+
self.stream.write(text)
|
|
191
|
+
flush = getattr(self.stream, 'flush', None)
|
|
192
|
+
if callable(flush):
|
|
193
|
+
flush()
|
|
194
|
+
|
|
195
|
+
def invalidate(self):
|
|
196
|
+
"""Forget the screen contents: the next frame is drawn in full."""
|
|
197
|
+
self._lines = None
|
|
198
|
+
|
|
199
|
+
def start(self):
|
|
200
|
+
if self._started:
|
|
201
|
+
return self
|
|
202
|
+
self._started = True
|
|
203
|
+
self._lines = None
|
|
204
|
+
if self.is_terminal:
|
|
205
|
+
codes = (ALT_SCREEN_ON if self.screen else '') + \
|
|
206
|
+
(CURSOR_HIDE if self.hide_cursor else '')
|
|
207
|
+
if codes:
|
|
208
|
+
self._write(codes)
|
|
209
|
+
return self
|
|
210
|
+
|
|
211
|
+
def stop(self):
|
|
212
|
+
if not self._started:
|
|
213
|
+
return
|
|
214
|
+
self._started = False
|
|
215
|
+
if self.is_terminal:
|
|
216
|
+
codes = (CURSOR_SHOW if self.hide_cursor else '') + \
|
|
217
|
+
(ALT_SCREEN_OFF if self.screen else '')
|
|
218
|
+
if codes:
|
|
219
|
+
self._write(codes)
|
|
220
|
+
if self.screen and not self.transient and self._last:
|
|
221
|
+
self._write(self._last + '\n')
|
|
222
|
+
|
|
223
|
+
def update(self, renderable=None):
|
|
224
|
+
"""Swap in ``renderable`` (if given) and redraw; returns the frame."""
|
|
225
|
+
if renderable is not None:
|
|
226
|
+
self.renderable = renderable
|
|
227
|
+
if not self._started:
|
|
228
|
+
self.start()
|
|
229
|
+
frame = self._frame()
|
|
230
|
+
if self.is_terminal:
|
|
231
|
+
lines = frame.split('\n')
|
|
232
|
+
size = get_window_size() if self.width is None or \
|
|
233
|
+
self.height is None else None
|
|
234
|
+
if self.diff and self._lines is not None and size == self._size:
|
|
235
|
+
out = _fx.render_diff(self._lines, lines)
|
|
236
|
+
if out:
|
|
237
|
+
self._write(out)
|
|
238
|
+
else:
|
|
239
|
+
body = (CLEAR_LINE + '\n').join(lines)
|
|
240
|
+
self._write(HOME + body + CLEAR_LINE + CLEAR_BELOW)
|
|
241
|
+
self._lines = lines
|
|
242
|
+
self._size = size
|
|
243
|
+
else:
|
|
244
|
+
self._write(frame + '\n')
|
|
245
|
+
self._last = frame
|
|
246
|
+
self.frames += 1
|
|
247
|
+
return frame
|
|
248
|
+
|
|
249
|
+
refresh = update
|
|
250
|
+
|
|
251
|
+
def run(self, step=None, interval=1.0, iterations=None, sleep=time.sleep):
|
|
252
|
+
"""
|
|
253
|
+
Redraw every ``interval`` seconds, calling ``step(frame_number)``
|
|
254
|
+
first; stop after ``iterations`` frames, when ``step`` returns
|
|
255
|
+
``False``, or on Ctrl-C.
|
|
256
|
+
"""
|
|
257
|
+
count = 0
|
|
258
|
+
with self:
|
|
259
|
+
try:
|
|
260
|
+
while iterations is None or count < iterations:
|
|
261
|
+
if step is not None and step(count) is False:
|
|
262
|
+
break
|
|
263
|
+
self.update()
|
|
264
|
+
count += 1
|
|
265
|
+
if iterations is None or count < iterations:
|
|
266
|
+
sleep(interval)
|
|
267
|
+
except KeyboardInterrupt:
|
|
268
|
+
pass
|
|
269
|
+
return count
|
|
270
|
+
|
|
271
|
+
def __enter__(self):
|
|
272
|
+
return self.start()
|
|
273
|
+
|
|
274
|
+
def __exit__(self, *exc):
|
|
275
|
+
self.stop()
|
|
276
|
+
return False
|