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.
@@ -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()
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