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