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/__init__.py +14 -0
- cmdgui/_platform.py +126 -0
- cmdgui/inputs.py +214 -0
- cmdgui/layout.py +249 -0
- cmdgui/shorts.py +307 -0
- cmdgui/view.py +713 -0
- cmdgui/widgets.py +577 -0
- cmdgui-0.1.0.dist-info/METADATA +263 -0
- cmdgui-0.1.0.dist-info/RECORD +11 -0
- cmdgui-0.1.0.dist-info/WHEEL +5 -0
- cmdgui-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
|