cmdgui 0.1.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.
cmdgui/view.py ADDED
@@ -0,0 +1,713 @@
1
+ from __future__ import annotations
2
+
3
+ import _thread
4
+ import atexit
5
+ import signal
6
+ import sys
7
+ import threading
8
+ import traceback
9
+ import copy
10
+ from typing import Any, Callable, Optional, TypeVar, Union
11
+ from .shorts import *
12
+ from .widgets import *
13
+ from .widgets import W
14
+ from .layout import parse_layout, parse_size, place, LayoutError
15
+ from . import inputs
16
+
17
+ P = TypeVar("P", bound="Popup")
18
+
19
+ # With keep_typing, these keys go to the popup instead of the focused text box
20
+ POPUP_KEYS = {"up", "down", "page_up", "page_down", "enter"}
21
+
22
+ # Setup terminal
23
+ def setup():
24
+ write(ESC + "?1049h" + hide_cursor()) # alternative screen
25
+ inputs.enable()
26
+
27
+ def teardown():
28
+ inputs.disable()
29
+ write(show_cursor() + ESC + "?1049l") # back to the normal screen
30
+ # Anything written to stderr (like a traceback) was hidden on the alternate screen
31
+ errors = inputs.captured_stderr()
32
+ if errors:
33
+ sys.stderr.write(errors)
34
+ sys.stderr.flush()
35
+
36
+
37
+ def _draw_borders(canvas, frames, titles, focused, theme):
38
+ """Draw every border at once, so shared edges are one line and meeting
39
+ lines get the right junction (├ ┬ ┼ ...). The focused frame is drawn in
40
+ the focus colour. titles: frame name -> title (or None)."""
41
+ links = {} # (x, y) -> directions that cell connects to
42
+ def link(x, y, direction):
43
+ links[(x, y)] = links.get((x, y), 0) | direction
44
+ for x, y, w, h in frames.values():
45
+ right, bottom = x + w - 1, y + h - 1
46
+ for cx in range(x, right):
47
+ for cy in (y, bottom):
48
+ link(cx, cy, RIGHT)
49
+ link(cx + 1, cy, LEFT)
50
+ for cy in range(y, bottom):
51
+ for cx in (x, right):
52
+ link(cx, cy, DOWN)
53
+ link(cx, cy + 1, UP)
54
+ for (x, y), directions in links.items():
55
+ canvas.put(x, y, LINE_CHARS[directions], theme["border"])
56
+
57
+ def restyle(x, y, s):
58
+ if 0 <= x < canvas.width and 0 <= y < canvas.height:
59
+ canvas.styles[y][x] = s
60
+ for name, (x, y, w, h) in frames.items():
61
+ if name == focused:
62
+ s = theme["border_focus"]
63
+ for cx in range(x, x + w):
64
+ restyle(cx, y, s)
65
+ restyle(cx, y + h - 1, s)
66
+ for cy in range(y, y + h):
67
+ restyle(x, cy, s)
68
+ restyle(x + w - 1, cy, s)
69
+ # Title on the top edge, stopping before any junction
70
+ title = titles.get(name)
71
+ if not title: continue
72
+ title_style = theme["border_focus"] if name == focused else theme["title"]
73
+ cx = x + 2
74
+ for char in f" {title} ":
75
+ if cx > x + w - 3 or links.get((cx, y)) != LEFT | RIGHT: break
76
+ cx += canvas.put(cx, y, char, title_style)
77
+
78
+
79
+ def _spec(preferred, natural):
80
+ """A widget's size range, raised to fit its content if it has room to grow."""
81
+ low, high = parse_size(preferred)
82
+ if natural is not None:
83
+ low = max(low, natural if high is None else min(natural, high))
84
+ return f"{low}+" if high is None else f"{low}-{max(low, high)}"
85
+
86
+
87
+ class Group():
88
+ """Named widgets arranged by a layout string. View and Popup are both groups:
89
+ widgets come from the layout, class attributes and keyword arguments, and
90
+ are found with group.name, group["name"] or group.get("name", Kind)."""
91
+ layout: Optional[str] = None # subclasses can set the layout here
92
+ _kind = "group" # for messages: "view" or "popup"
93
+
94
+ def _init_group(self, layout, widgets):
95
+ """Collect, check and create the widgets. Views run this before touching
96
+ the terminal, so a mistake doesn't leave it in raw mode."""
97
+ self.widgets: list[Widget] = []
98
+ self.named: dict[str, Widget] = {} # name -> widget, for widgets from the layout
99
+ self.frames: dict[Any, tuple[int, int, int, int]] = {} # name -> border rectangle
100
+
101
+ # Class attributes (copied, so each instance gets its own) and keyword arguments
102
+ provided: dict[str, Any] = {}
103
+ for klass in reversed(type(self).__mro__):
104
+ for name, value in vars(klass).items():
105
+ if isinstance(value, (Widget, Popup)):
106
+ provided[name] = value.copy()
107
+ for name, value in widgets.items():
108
+ if not isinstance(value, (Widget, Popup)):
109
+ raise TypeError(f"{type(self).__name__}() got an unexpected argument '{name}' "
110
+ f"(widgets must be Widget instances)")
111
+ provided[name] = value
112
+ for name in [name for name, value in provided.items() if isinstance(value, Popup)]:
113
+ self._add_popup(name, provided.pop(name))
114
+
115
+ self.layout = layout if layout is not None else type(self).layout or self._default_layout(provided)
116
+ if provided and not self.layout:
117
+ raise LayoutError(f"widgets were given ({', '.join(provided)}) but there's no layout to put them in")
118
+ self.grid = parse_layout(self.layout, types=WIDGET_TYPES, names=provided) if self.layout else None
119
+ if not self.grid:
120
+ return
121
+ unused = [name for name in provided if name not in self.grid.slots]
122
+ if unused:
123
+ raise LayoutError(f"widget{'s' if len(unused) > 1 else ''} {', '.join(unused)} "
124
+ f"{'are' if len(unused) > 1 else 'is'} not in the layout")
125
+ for name, slot in self.grid.slots.items():
126
+ attr = getattr(type(self), name, None)
127
+ if name in self.__dict__ or (attr is not None and not isinstance(attr, Widget)):
128
+ raise LayoutError(f"widget name '{name}' clashes with {self._kind}.{name}, pick another name")
129
+ widget = provided.get(name)
130
+ if widget is None:
131
+ widget = WIDGET_TYPES[slot.type]()
132
+ elif slot.type is not None and not isinstance(widget, WIDGET_TYPES[slot.type]):
133
+ raise LayoutError(f"the layout says '{name}' is a {slot.type}, "
134
+ f"but it was given a {type(widget).__name__}")
135
+ widget.name = name
136
+ self.widgets.append(widget)
137
+ self.named[name] = widget
138
+ for name, widget in self.named.items():
139
+ self.__dict__[name] = widget # group.name finds this instance's copy, not the class attribute
140
+
141
+ def _add_popup(self, name, popup) -> None:
142
+ raise TypeError(f"{type(self).__name__}() got a popup '{name}', only views can hold popups")
143
+
144
+ def _default_layout(self, provided):
145
+ return None
146
+
147
+ def __getitem__(self, name: str) -> Widget:
148
+ return self.named[name]
149
+
150
+ def get(self, name: str, kind: type[W]) -> W:
151
+ """A widget by name, typed for your editor: view.get("heading", Label)"""
152
+ widget = self.named[name]
153
+ if not isinstance(widget, kind):
154
+ raise TypeError(f"'{name}' is a {type(widget).__name__}, not a {kind.__name__}")
155
+ return widget
156
+
157
+ def __getattr__(self, name):
158
+ # Only called for attributes that don't exist, so view.start finds the widget "start"
159
+ named = self.__dict__.get("named", {})
160
+ if name in named:
161
+ return named[name]
162
+ raise AttributeError(f"{self._kind} has no attribute or widget named '{name}'")
163
+
164
+ def _place(self, width, height, fit_content=False, outer=False):
165
+ """Work out where the widgets go in a width x height box (positions relative to it).
166
+ fit_content: grow widgets to their content_size(); outer: leave room for a border around it all."""
167
+ sizes, borders = {}, {}
168
+ for name, widget in self.named.items():
169
+ if fit_content:
170
+ content_w, content_h = widget.content_size()
171
+ sizes[name] = (_spec(widget.preferred_width, content_w), _spec(widget.preferred_height, content_h))
172
+ else:
173
+ sizes[name] = (widget.preferred_width, widget.preferred_height)
174
+ slot_border = self.grid.slots[name].border
175
+ borders[name] = widget.border if slot_border is None else slot_border
176
+ return place(self.grid, width, height, sizes, borders, outer=outer)
177
+
178
+
179
+ class Popup(Group):
180
+ """A box that floats over the view, with its own layout of widgets:
181
+
182
+ class Confirm(Popup):
183
+ layout = '''
184
+ message -
185
+ yes no
186
+ '''
187
+ message = Label("Delete everything?")
188
+ yes = Button("Yes")
189
+ no = Button("No")
190
+
191
+ view.show(Confirm())
192
+
193
+ A popup with one widget doesn't need a layout: Popup(Menu(items)), or a
194
+ subclass with a single widget attribute.
195
+ Settings can be class attributes or constructor arguments."""
196
+ _kind = "popup"
197
+ modal: bool = True # block everything underneath while open
198
+ close_on_escape: bool = True
199
+ close_on_outside_click: bool = False
200
+ keep_typing: bool = False # the focused text box keeps getting typed text; arrows and Enter come here
201
+ border: bool = True
202
+ title: Optional[str] = None
203
+ width: Optional[int] = None # outer size, including the border; None to fit the content
204
+ height: Optional[int] = None
205
+
206
+ def __init__(self, layout: Union[str, Widget, None] = None, *,
207
+ modal: Optional[bool] = None, close_on_escape: Optional[bool] = None,
208
+ close_on_outside_click: Optional[bool] = None, keep_typing: Optional[bool] = None,
209
+ border: Optional[bool] = None, title: Optional[str] = None,
210
+ width: Optional[int] = None, height: Optional[int] = None,
211
+ on_close: Optional[Callable[[], Any]] = None, **widgets: Widget):
212
+ settings = dict(modal=modal, close_on_escape=close_on_escape, keep_typing=keep_typing,
213
+ close_on_outside_click=close_on_outside_click, border=border,
214
+ title=title, width=width, height=height)
215
+ for key, value in settings.items():
216
+ if value is not None: setattr(self, key, value)
217
+ self.close_callback = on_close
218
+ self.view: Optional[View] = None
219
+ self.x = self.y = self.w = self.h = 0 # on screen, including the border
220
+ self._anchor: tuple[str, Any] = ("center", None)
221
+ self._previous_focus: Optional[Widget] = None
222
+
223
+ if isinstance(layout, Widget):
224
+ widgets = {"content": layout, **widgets}
225
+ layout = None
226
+ self._init_group(layout, widgets)
227
+ if not self.grid:
228
+ raise LayoutError("a popup needs a layout or at least one widget")
229
+ self.init()
230
+
231
+ def _default_layout(self, provided):
232
+ return next(iter(provided)) if len(provided) == 1 else None # a single widget fills the popup
233
+
234
+ def init(self) -> None:
235
+ """Override to wire up widgets, e.g. self.no.on_click(self.close)."""
236
+
237
+ @property
238
+ def is_open(self) -> bool:
239
+ return self.view is not None
240
+
241
+ def on_close(self, callback: Optional[Callable[[], Any]]) -> None:
242
+ """Call callback() whenever the popup closes."""
243
+ self.close_callback = callback
244
+
245
+ def close(self) -> None:
246
+ """Close the popup (does nothing if it isn't open)."""
247
+ if self.view: self.view._close(self)
248
+
249
+ def copy(self: P) -> P:
250
+ """A separate copy with its own widgets (init() runs again for it)."""
251
+ new = copy.copy(self)
252
+ new.__dict__.update(view=None, widgets=[], named={}, frames={}, _previous_focus=None)
253
+ for name, widget in self.named.items():
254
+ widget = widget.copy()
255
+ widget.name = name
256
+ new.widgets.append(widget)
257
+ new.named[name] = widget
258
+ new.__dict__[name] = widget
259
+ new.init()
260
+ return new
261
+
262
+ def _contains(self, x, y):
263
+ return self.x <= x < self.x + self.w and self.y <= y < self.y + self.h
264
+
265
+
266
+ class View(Group):
267
+ """A terminal GUI. Three ways to fill it, which can be mixed:
268
+
269
+ View("label[heading] \n stdout") # types in the layout
270
+ View("heading \n stdout", heading=Label("Hi")) # widgets passed in
271
+
272
+ class UI(View): # a subclass: typed in your editor
273
+ layout = "heading \n stdout"
274
+ heading = Label("Hi")
275
+ """
276
+ _kind = "view"
277
+
278
+ def __init__(self, layout: Optional[str] = None, theme: Optional[dict] = None,
279
+ quit_key: Optional[str] = "q", **widgets: Widget):
280
+ self.lock = threading.RLock() # reentrant, so widgets can call view methods while handling input
281
+ self.running = False
282
+ self.thread = None
283
+ self.too_small = False
284
+ self.focused: Optional[Widget] = None
285
+ self.theme = {**DEFAULT_THEME, **(theme or {})}
286
+ self.bindings = {} # key name -> function
287
+ self.size = (0, 0)
288
+ self._base = None # borders (or the "too small" message), widgets go on top
289
+ self._shown = None # what's on the terminal right now
290
+ self._relayout = True
291
+ self._redraw_base = True
292
+ self._done = threading.Event()
293
+ self._waiting = False
294
+ self._quitting = False
295
+ self._exit_code = 0 # 1 after a crash inside the view (e.g. in a callback)
296
+ if quit_key:
297
+ self.bindings[quit_key] = self.quit
298
+
299
+ self.popups: list[Popup] = [] # open popups, bottom to top
300
+
301
+ # Before touching the terminal, so a layout mistake doesn't leave it in raw mode
302
+ self._init_group(layout, widgets)
303
+ for widget in self.widgets:
304
+ widget.view = self
305
+ if self.grid:
306
+ self._place(0, 0) # checks the widgets' sizes are valid, before touching the terminal
307
+
308
+ self._install_sigint()
309
+ self.running = True
310
+ atexit.register(self.stop)
311
+ try:
312
+ setup()
313
+ self.size = screen_size()
314
+ self._render()
315
+ self.thread = threading.Thread(target=self._input_loop, daemon=True)
316
+ self.thread.start()
317
+ except BaseException:
318
+ self.stop() # restore the terminal so the error is visible
319
+ raise
320
+
321
+ def _add_popup(self, name, popup) -> None:
322
+ attr = getattr(type(self), name, None)
323
+ if attr is not None and not isinstance(attr, Popup):
324
+ raise LayoutError(f"popup name '{name}' clashes with view.{name}, pick another name")
325
+ self.__dict__[name] = popup # view.name finds this view's copy
326
+
327
+ def __enter__(self):
328
+ return self
329
+
330
+ def __exit__(self, *exc):
331
+ self.stop()
332
+ return False
333
+
334
+ # --- Widgets ---
335
+
336
+ def add(self, widget: W) -> W:
337
+ """Add a widget outside the layout. Set its x, y, width, height yourself."""
338
+ with self.lock:
339
+ widget.view = self
340
+ self.widgets.append(widget)
341
+ widget.refresh()
342
+ return widget
343
+
344
+ def refresh(self) -> None:
345
+ """Redraw everything on the next frame."""
346
+ for widget in self._all_widgets():
347
+ widget._dirty = True
348
+ self._wake()
349
+
350
+ # --- Popups ---
351
+
352
+ def show(self, popup: P, *, at: Optional[tuple[int, int]] = None,
353
+ below: Optional[Widget] = None, above: Optional[Widget] = None) -> P:
354
+ """Open a popup on top of everything: in the middle of the screen, at=(x, y),
355
+ or below= / above= a widget (flipping if there's no room). If it's already
356
+ open, it just moves."""
357
+ anchor = ("at", at) if at else ("below", below) if below else ("above", above) if above else ("center", None)
358
+ with self.lock:
359
+ popup._anchor = anchor
360
+ if popup.view is self:
361
+ self._place_popup(popup)
362
+ self._redraw_base = True
363
+ else:
364
+ if popup.view: popup.view._close(popup)
365
+ popup.view = self
366
+ for widget in popup.widgets:
367
+ widget.view = self
368
+ widget._popup = popup
369
+ widget._dirty = True
370
+ self.popups.append(popup)
371
+ self._place_popup(popup)
372
+ self._redraw_base = True
373
+ popup._previous_focus = self.focused
374
+ if not popup.keep_typing:
375
+ self.focus(next((w for w in popup.widgets if w.focusable), None))
376
+ self._wake()
377
+ return popup
378
+
379
+ def alert(self, message: str, title: Optional[str] = None,
380
+ on_close: Optional[Callable[[], Any]] = None) -> Popup:
381
+ """Show a message with an OK button."""
382
+ ok = Button("OK")
383
+ popup = Popup("message \n ok", message=Text(message, align="center"), ok=ok,
384
+ title=title, on_close=on_close)
385
+ ok.on_click(popup.close)
386
+ return self.show(popup)
387
+
388
+ def _close(self, popup):
389
+ with self.lock:
390
+ if popup not in self.popups: return
391
+ self.popups.remove(popup)
392
+ focused_inside = self.focused is None or self.focused._popup is popup
393
+ for widget in popup.widgets:
394
+ widget.view = None
395
+ widget._popup = None
396
+ popup.view = None
397
+ self._redraw_base = True
398
+ if focused_inside:
399
+ previous = popup._previous_focus
400
+ still_there = previous is not None and (previous._popup is None or previous._popup in self.popups)
401
+ self.focused = None # the old focus is gone, don't call its on_blur with the view detached
402
+ self.focus(previous if still_there else None)
403
+ popup._previous_focus = None
404
+ self._wake()
405
+ if popup.close_callback: popup.close_callback()
406
+
407
+ def _all_widgets(self):
408
+ return self.widgets + [w for popup in self.popups for w in popup.widgets]
409
+
410
+ def _active_widgets(self):
411
+ """Widgets that can get mouse input and focus: everything above the top modal popup."""
412
+ for i in range(len(self.popups) - 1, -1, -1):
413
+ if self.popups[i].modal:
414
+ return [w for popup in self.popups[i:] for w in popup.widgets]
415
+ return self._all_widgets()
416
+
417
+ def _layer_at(self, x, y):
418
+ """The topmost popup at (x, y), or None for the main layout."""
419
+ for popup in reversed(self.popups):
420
+ if popup._contains(x, y):
421
+ return popup
422
+ return None
423
+
424
+ def _box(self, widget):
425
+ """A widget's rectangle including its border."""
426
+ frames = widget._popup.frames if widget._popup else self.frames
427
+ return frames.get(widget.name) or (widget.x, widget.y, widget.width, widget.height)
428
+
429
+ def _place_popup(self, popup):
430
+ """Size the popup to its content (or its width/height) and position it."""
431
+ screen_w, screen_h = self.size
432
+ natural = popup._place(0, 0, fit_content=True, outer=popup.border)
433
+ w = min(popup.width or natural.min_width, screen_w)
434
+ h = min(popup.height or natural.min_height, screen_h)
435
+
436
+ kind, target = popup._anchor
437
+ if kind == "at":
438
+ x, y = target
439
+ elif kind in ("below", "above") and target is not None:
440
+ bx, by, bw, bh = self._box(target)
441
+ x, below_y, above_y = bx, by + bh, by - h
442
+ if kind == "below":
443
+ y = below_y if below_y + h <= screen_h or above_y < 0 else above_y
444
+ else:
445
+ y = above_y if above_y >= 0 or below_y + h > screen_h else below_y
446
+ else:
447
+ x, y = (screen_w - w) // 2, (screen_h - h) // 2
448
+ x, y = max(0, min(x, screen_w - w)), max(0, min(y, screen_h - h))
449
+
450
+ placement = popup._place(w, h, fit_content=True, outer=popup.border)
451
+ inner = 1 if popup.border else 0
452
+ popup.x, popup.y, popup.w, popup.h = x, y, w, h
453
+ popup.frames = {name: (x + fx, y + fy, fw, fh) for name, (fx, fy, fw, fh) in placement.frames.items()}
454
+ if popup.border:
455
+ popup.frames[None] = (x, y, w, h)
456
+ for name, (rx, ry, rw, rh) in placement.rects.items():
457
+ widget = popup.named[name]
458
+ # Clip to the inside of the popup, in case the screen is too small for it
459
+ rw = max(0, min(rw, w - inner - rx))
460
+ rh = max(0, min(rh, h - inner - ry))
461
+ if (widget.x, widget.y, widget.width, widget.height) != (x + rx, y + ry, rw, rh):
462
+ widget.x, widget.y, widget.width, widget.height = x + rx, y + ry, rw, rh
463
+ widget._dirty = True
464
+ widget.on_resize()
465
+
466
+ def relayout(self) -> None:
467
+ """Work out the layout again, e.g. after a widget's preferred size changed."""
468
+ self._relayout = True
469
+ self._wake()
470
+
471
+ # --- Focus and keys ---
472
+
473
+ def focus(self, widget: Optional[Widget]) -> None:
474
+ """Give a widget keyboard focus (None to remove focus)."""
475
+ with self.lock:
476
+ old = self.focused
477
+ if widget is old: return
478
+ self.focused = widget
479
+ self._redraw_base = True # the focus border changes colour
480
+ if old:
481
+ old.refresh()
482
+ old.on_blur()
483
+ if widget:
484
+ widget.refresh()
485
+ widget.on_focus()
486
+ self._wake()
487
+
488
+ def focus_next(self, step: int = 1) -> None:
489
+ """Move focus to the next (or previous, step=-1) focusable widget."""
490
+ with self.lock:
491
+ focusable = [w for w in self._active_widgets() if w.focusable]
492
+ if not focusable: return
493
+ if self.focused in focusable:
494
+ index = (focusable.index(self.focused) + step) % len(focusable)
495
+ else:
496
+ index = 0 if step > 0 else -1
497
+ self.focus(focusable[index])
498
+
499
+ def on_key(self, key: str, callback: Optional[Callable[[], Any]]) -> None:
500
+ """Call callback() when key is pressed, e.g. view.on_key("ctrl+s", save).
501
+ Pass None to remove a binding."""
502
+ if callback: self.bindings[key] = callback
503
+ else: self.bindings.pop(key, None)
504
+
505
+ # --- Lifetime ---
506
+
507
+ def wait(self) -> None:
508
+ """Block until the view is closed (quit key, view.quit(), or Ctrl+C)."""
509
+ self._waiting = True
510
+ try:
511
+ while not self._done.wait(0.1):
512
+ pass
513
+ except KeyboardInterrupt:
514
+ pass
515
+ finally:
516
+ self._waiting = False
517
+ self.stop()
518
+ if self._exit_code:
519
+ raise SystemExit(self._exit_code)
520
+
521
+ def quit(self) -> None:
522
+ """Close the view. If the main program isn't in view.wait(), it's
523
+ interrupted like Ctrl+C so the program ends."""
524
+ interrupt = not self._waiting and threading.current_thread() is not threading.main_thread()
525
+ self.stop()
526
+ if interrupt:
527
+ self._quitting = True
528
+ _thread.interrupt_main() # runs our SIGINT handler in the main thread, which exits cleanly
529
+
530
+ def stop(self) -> None:
531
+ """Close the view and put the terminal back to normal."""
532
+ if self.running:
533
+ self.running = False
534
+ if self.thread and self.thread is not threading.current_thread():
535
+ self._wake()
536
+ self.thread.join()
537
+ teardown()
538
+ self._done.set()
539
+
540
+ # --- Internals ---
541
+
542
+ def _wake(self):
543
+ inputs.wake()
544
+
545
+ def _redraw_borders(self):
546
+ self._redraw_base = True
547
+ self._wake()
548
+
549
+ def _install_sigint(self):
550
+ """Signal handlers can only be set from the main thread, so set one up now
551
+ that the quit key can trigger later: it ends the program with exit code 0.
552
+ Real Ctrl+C still raises KeyboardInterrupt as usual."""
553
+ if threading.current_thread() is not threading.main_thread(): return
554
+ previous = signal.getsignal(signal.SIGINT)
555
+ def handler(signum, frame):
556
+ if self._quitting:
557
+ raise SystemExit(self._exit_code)
558
+ if callable(previous):
559
+ return previous(signum, frame)
560
+ raise KeyboardInterrupt
561
+ signal.signal(signal.SIGINT, handler)
562
+
563
+ def _input_loop(self):
564
+ while self.running:
565
+ try:
566
+ size = screen_size()
567
+ if size != self.size:
568
+ self.size = size
569
+ self._relayout = True
570
+ for input in inputs.read_inputs(timeout=0.05):
571
+ with self.lock:
572
+ if not self.running: return
573
+ self._dispatch(input)
574
+ self._render()
575
+ except Exception:
576
+ traceback.print_exc() # goes to the captured stderr, shown after the view closes
577
+ self._exit_code = 1
578
+ self.quit()
579
+ return
580
+
581
+ def _dispatch(self, input):
582
+ inputs.update_state(input)
583
+ if self.too_small and input.type not in ("stdout", "stderr"): return # keep collecting prints
584
+
585
+ top = self.popups[-1] if self.popups else None
586
+
587
+ if input.type == "key":
588
+ key, char, focused = input.details["key"], input.details["char"], self.focused
589
+ if top and key == "escape" and top.close_on_escape:
590
+ top.close()
591
+ return
592
+ if top and top.keep_typing and not char and key in POPUP_KEYS:
593
+ target = next((w for w in top.widgets if w.focusable), None)
594
+ if target:
595
+ target.on_input(input) # e.g. arrows move through a menu while typing
596
+ return
597
+ if focused and focused.captures_text and char:
598
+ focused.on_input(input) # typing into a text box beats key bindings
599
+ elif key in self.bindings:
600
+ self.bindings[key]()
601
+ elif key in ("tab", "shift_tab"):
602
+ self.focus_next(1 if key == "tab" else -1)
603
+ elif focused:
604
+ focused.on_input(input)
605
+ return
606
+
607
+ if input.type.startswith("mouse"):
608
+ if input.type == "mouse_down" and top and not top._contains(mouse.x, mouse.y):
609
+ if top.close_on_outside_click:
610
+ top.close()
611
+ if top.modal: return # the click only closes it
612
+ elif top.modal:
613
+ return
614
+ active = self._active_widgets()
615
+ if input.type == "mouse_down":
616
+ # Clicking a focusable widget focuses it (mouse_over skips covered widgets)
617
+ for widget in reversed(active):
618
+ keeps_typing = widget._popup is not None and widget._popup.keep_typing
619
+ if widget.focusable and widget.mouse_over() and not keeps_typing:
620
+ self.focus(widget)
621
+ break
622
+ for widget in active:
623
+ widget.on_input(input)
624
+ return
625
+
626
+ for widget in self._all_widgets():
627
+ widget.on_input(input)
628
+
629
+ def _render(self):
630
+ """Draw a frame: redraw changed widgets, stack everything on top of the
631
+ borders, and send only the cells that differ from what's on screen."""
632
+ with self.lock:
633
+ if not self.running: return
634
+ if self._relayout:
635
+ self._apply_layout()
636
+ if self.too_small: return
637
+
638
+ changed = False
639
+ if self._redraw_base:
640
+ self._draw_base()
641
+ changed = True
642
+ for popup in self.popups:
643
+ if any(w._dirty for w in popup.widgets):
644
+ old = (popup.x, popup.y, popup.w, popup.h)
645
+ self._place_popup(popup) # its content may want a different size now
646
+ if (popup.x, popup.y, popup.w, popup.h) != old: changed = True
647
+ for widget in self._all_widgets():
648
+ if widget._dirty:
649
+ canvas = Canvas(max(0, widget.width), max(0, widget.height))
650
+ widget.draw(canvas)
651
+ widget._canvas = canvas
652
+ widget._dirty = False # after drawing, so changes made while drawing don't loop
653
+ changed = True
654
+ if not changed: return
655
+
656
+ screen = self._base.copy()
657
+ for widget in self.widgets:
658
+ if widget._canvas: screen.blit(widget._canvas, widget.x, widget.y)
659
+ for popup in self.popups:
660
+ screen.fill(popup.x, popup.y, popup.w, popup.h) # hide what's underneath
661
+ titles: dict[Any, Optional[str]] = {name: w.title for name, w in popup.named.items()}
662
+ titles[None] = popup.title
663
+ focused = self.focused.name if self.focused and self.focused._popup is popup else False
664
+ _draw_borders(screen, popup.frames, titles, focused, self.theme)
665
+ for widget in popup.widgets:
666
+ if widget._canvas: screen.blit(widget._canvas, widget.x, widget.y)
667
+ out = screen.diff(self._shown)
668
+ self._shown = screen
669
+ if out: write(render_frame(out))
670
+
671
+ def _apply_layout(self):
672
+ """Give layout widgets their rectangles for the current screen size."""
673
+ self._relayout = False
674
+ self.too_small = False
675
+ self._shown = None # everything gets drawn again
676
+ write(clear_screen())
677
+ width, height = self.size
678
+ self._base = Canvas(width, height)
679
+ if self.grid:
680
+ placement = self._place(width, height)
681
+ if not placement.fits:
682
+ self.too_small = True
683
+ self._draw_too_small(placement.min_width, placement.min_height)
684
+ return
685
+ for name, (x, y, w, h) in placement.rects.items():
686
+ widget = self.named[name]
687
+ widget.x, widget.y, widget.width, widget.height = x, y, w, h
688
+ widget.on_resize()
689
+ self.frames = placement.frames
690
+ for popup in self.popups:
691
+ self._place_popup(popup)
692
+ self._redraw_base = True
693
+ for widget in self._all_widgets():
694
+ widget._dirty = True
695
+
696
+ def _draw_base(self):
697
+ """Draw the layout's borders. The focused widget's border is highlighted."""
698
+ self._redraw_base = False
699
+ width, height = self.size
700
+ self._base = Canvas(width, height)
701
+ titles = {name: self.named[name].title or name for name in self.frames}
702
+ focused = self.focused.name if self.focused and self.focused._popup is None else None
703
+ _draw_borders(self._base, self.frames, titles, focused, self.theme)
704
+
705
+ def _draw_too_small(self, need_w, need_h):
706
+ width, height = self.size
707
+ lines = ["Terminal too small", f"need {need_w}x{need_h}, have {width}x{height}"]
708
+ for i, line in enumerate(lines):
709
+ line = fit(line, width)
710
+ self._base.text(max(0, (width - len(line)) // 2), max(0, height // 2 - 1 + i), line)
711
+ write(render_frame(self._base.diff(None)))
712
+ self._shown = self._base
713
+