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/widgets.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import inspect
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, TypeVar, Union
|
|
8
|
+
from .shorts import *
|
|
9
|
+
from .inputs import Input, mouse
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
from typing import dataclass_transform
|
|
13
|
+
else:
|
|
14
|
+
def dataclass_transform(**kwargs):
|
|
15
|
+
return lambda cls: cls
|
|
16
|
+
|
|
17
|
+
# A size: 5 or "5" exactly, "5+" at least 5, "5-10" between, None for any size
|
|
18
|
+
Size = Union[int, str, None]
|
|
19
|
+
Align = Literal["left", "center", "right"]
|
|
20
|
+
W = TypeVar("W", bound="Widget")
|
|
21
|
+
|
|
22
|
+
_MISSING = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Field:
|
|
26
|
+
def __init__(self, default, default_factory, kw_only, alias):
|
|
27
|
+
self.default = default
|
|
28
|
+
self.default_factory = default_factory
|
|
29
|
+
self.kw_only = kw_only
|
|
30
|
+
self.alias = alias
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def field(default: Any = _MISSING, *, default_factory: Optional[Callable[[], Any]] = None,
|
|
34
|
+
kw_only: bool = True, alias: Optional[str] = None) -> Any:
|
|
35
|
+
"""Customise a widget field:
|
|
36
|
+
items: list = field(default_factory=list) # a fresh list per widget
|
|
37
|
+
text: str = field(default="", kw_only=False) # can be passed positionally
|
|
38
|
+
callback: ... = field(default=None, alias="on_click") # constructor argument name"""
|
|
39
|
+
return _Field(default, default_factory, kw_only, alias)
|
|
40
|
+
|
|
41
|
+
# Type name -> widget class, for layout strings. Filled in automatically.
|
|
42
|
+
WIDGET_TYPES = {}
|
|
43
|
+
|
|
44
|
+
# Styles used by the built-in widgets. Pass View(theme={...}) to change any of them.
|
|
45
|
+
DEFAULT_THEME = {
|
|
46
|
+
"border": "",
|
|
47
|
+
"border_focus": style(fg="cyan", bold=True),
|
|
48
|
+
"title": style(bold=True),
|
|
49
|
+
"text": "",
|
|
50
|
+
"dim": style(fg="bright_black"),
|
|
51
|
+
"error": style(fg="red"),
|
|
52
|
+
"button": "",
|
|
53
|
+
"button_hover": style(reverse=True),
|
|
54
|
+
"button_focus": style(fg="cyan", bold=True),
|
|
55
|
+
"cursor": style(reverse=True),
|
|
56
|
+
"selected": style(fg="black", bg="cyan"),
|
|
57
|
+
"selected_unfocused": style(reverse=True),
|
|
58
|
+
"progress": style(fg="green"),
|
|
59
|
+
"progress_empty": style(fg="bright_black"),
|
|
60
|
+
"header": style(bold=True, underline=True),
|
|
61
|
+
"on": style(fg="black", bg="green", bold=True),
|
|
62
|
+
"off": style(fg="bright_black", reverse=True),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Changing these means the layout has to be worked out again
|
|
66
|
+
LAYOUT_ATTRS = {"preferred_width", "preferred_height", "border"}
|
|
67
|
+
# Changing these means the borders have to be drawn again
|
|
68
|
+
BORDER_ATTRS = {"title"}
|
|
69
|
+
# Set by the view, so changing them shouldn't trigger a redraw
|
|
70
|
+
POSITION_ATTRS = {"x", "y", "width", "height", "name", "view"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass_transform(kw_only_default=True, field_specifiers=(field,))
|
|
74
|
+
class _FieldWidget:
|
|
75
|
+
"""Marks widgets as dataclass-like for editors: annotated class attributes
|
|
76
|
+
become constructor arguments, with autocomplete and type checking."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Widget(_FieldWidget):
|
|
80
|
+
# Fields: annotated attributes become constructor arguments (keyword-only
|
|
81
|
+
# unless field(kw_only=False)). Subclasses can change a default by just
|
|
82
|
+
# assigning it, e.g. `border = True`.
|
|
83
|
+
preferred_width: Size = None # sizes of the content, not counting the border
|
|
84
|
+
preferred_height: Size = None
|
|
85
|
+
border: bool = False # drawn by the view; {b} / {nb} in the layout overrides this
|
|
86
|
+
title: Optional[str] = None # shown in the border, defaults to the widget's name
|
|
87
|
+
|
|
88
|
+
# Class settings (not constructor arguments)
|
|
89
|
+
type_name = None # name used in layouts, defaults to the class name in snake_case
|
|
90
|
+
focusable = False # can be focused with Tab or a click, and then gets key presses
|
|
91
|
+
captures_text = False # when focused, typed characters go to it before key bindings
|
|
92
|
+
|
|
93
|
+
def __init_subclass__(cls, **kwargs):
|
|
94
|
+
super().__init_subclass__(**kwargs)
|
|
95
|
+
_collect_fields(cls)
|
|
96
|
+
name = cls.__dict__.get("type_name") or re.sub(r"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()
|
|
97
|
+
WIDGET_TYPES[name] = cls
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args, **kwargs):
|
|
100
|
+
self._ready = False # attribute changes only redraw once set up
|
|
101
|
+
self._dirty = True
|
|
102
|
+
self._canvas: Optional[Canvas] = None # last drawn content
|
|
103
|
+
# Where the widget is on screen. The view sets these from the layout;
|
|
104
|
+
# set them in init() for widgets added with view.add()
|
|
105
|
+
self.x = 0
|
|
106
|
+
self.y = 0
|
|
107
|
+
self.width = 0
|
|
108
|
+
self.height = 0
|
|
109
|
+
self.name: Optional[str] = None # set by the view for layout widgets
|
|
110
|
+
self.view: Any = None # the View showing this widget
|
|
111
|
+
self._popup: Any = None # the Popup this widget is in, if any
|
|
112
|
+
for name, info in type(self)._fields.items():
|
|
113
|
+
value = info.default_factory() if info.default_factory else getattr(type(self), name, None)
|
|
114
|
+
object.__setattr__(self, name, value)
|
|
115
|
+
self.init()
|
|
116
|
+
self._apply(args, kwargs)
|
|
117
|
+
self._ready = True
|
|
118
|
+
|
|
119
|
+
def _apply(self, args, kwargs):
|
|
120
|
+
"""Set attributes from constructor-style arguments."""
|
|
121
|
+
fields = type(self)._fields
|
|
122
|
+
positional = [name for name, info in fields.items() if not info.kw_only]
|
|
123
|
+
if len(args) > len(positional):
|
|
124
|
+
raise TypeError(f"{type(self).__name__}() takes {len(positional)} positional "
|
|
125
|
+
f"argument{'s' if len(positional) != 1 else ''} ({', '.join(positional) or 'none'}), "
|
|
126
|
+
f"got {len(args)}")
|
|
127
|
+
names = {(info.alias or name): name for name, info in fields.items()}
|
|
128
|
+
values = dict(zip(positional, args))
|
|
129
|
+
for key, value in kwargs.items():
|
|
130
|
+
if key in names:
|
|
131
|
+
name = names[key]
|
|
132
|
+
elif not key.startswith("_") and key in self.__dict__:
|
|
133
|
+
name = key # widgets that set attributes in init() instead of declaring fields
|
|
134
|
+
else:
|
|
135
|
+
raise TypeError(f"{type(self).__name__}() got an unexpected argument '{key}' "
|
|
136
|
+
f"(accepts: {', '.join(sorted(names))})")
|
|
137
|
+
if name in values:
|
|
138
|
+
raise TypeError(f"{type(self).__name__}() got '{key}' twice")
|
|
139
|
+
values[name] = value
|
|
140
|
+
for name, value in values.items():
|
|
141
|
+
setattr(self, name, value)
|
|
142
|
+
|
|
143
|
+
def set(self: W, **kwargs) -> W:
|
|
144
|
+
"""Change several attributes at once, redrawing once: label.set(text="Hi", align="center")"""
|
|
145
|
+
object.__setattr__(self, "_ready", False)
|
|
146
|
+
try:
|
|
147
|
+
self._apply((), kwargs)
|
|
148
|
+
finally:
|
|
149
|
+
object.__setattr__(self, "_ready", True)
|
|
150
|
+
if self.view and any(key in LAYOUT_ATTRS for key in kwargs):
|
|
151
|
+
self.view.relayout()
|
|
152
|
+
elif self.view and any(key in BORDER_ATTRS for key in kwargs):
|
|
153
|
+
self.view._redraw_borders()
|
|
154
|
+
self.refresh()
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def copy(self: W) -> W:
|
|
158
|
+
"""A separate copy of this widget, not attached to any view. Lists and
|
|
159
|
+
dicts are copied so the two don't share items; callbacks are shared."""
|
|
160
|
+
new = copy.copy(self)
|
|
161
|
+
for key, value in vars(new).items():
|
|
162
|
+
if isinstance(value, (list, dict, set)):
|
|
163
|
+
new.__dict__[key] = copy.copy(value)
|
|
164
|
+
new.__dict__.update(view=None, name=None, _popup=None, _canvas=None, _dirty=True)
|
|
165
|
+
return new
|
|
166
|
+
|
|
167
|
+
if not TYPE_CHECKING:
|
|
168
|
+
# Hidden from type checkers: a __setattr__ makes them accept any attribute
|
|
169
|
+
# name, and we want typos like `label.txt = ...` flagged.
|
|
170
|
+
def __setattr__(self, key, value):
|
|
171
|
+
# Changing any public attribute redraws the widget, so `button.text = "Go"` just works.
|
|
172
|
+
# Mutating a list in place (self.items.append) doesn't, call self.refresh() for that.
|
|
173
|
+
object.__setattr__(self, key, value)
|
|
174
|
+
if key.startswith("_") or not self.__dict__.get("_ready") or key in POSITION_ATTRS:
|
|
175
|
+
return
|
|
176
|
+
if key in LAYOUT_ATTRS:
|
|
177
|
+
if self.view: self.view.relayout()
|
|
178
|
+
elif key in BORDER_ATTRS:
|
|
179
|
+
if self.view: self.view._redraw_borders()
|
|
180
|
+
else:
|
|
181
|
+
self.refresh()
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def focused(self):
|
|
185
|
+
return self.view is not None and self.view.focused is self
|
|
186
|
+
|
|
187
|
+
def theme(self, key):
|
|
188
|
+
"""A style from the view's theme."""
|
|
189
|
+
return (self.view.theme if self.view else DEFAULT_THEME).get(key, "")
|
|
190
|
+
|
|
191
|
+
def refresh(self):
|
|
192
|
+
"""Redraw on the next frame. Safe to call from any thread."""
|
|
193
|
+
self._dirty = True
|
|
194
|
+
if self.view: self.view._wake()
|
|
195
|
+
|
|
196
|
+
def mouse_pos(self):
|
|
197
|
+
"""Mouse position relative to this widget's top-left corner."""
|
|
198
|
+
return mouse.x - self.x, mouse.y - self.y
|
|
199
|
+
|
|
200
|
+
def mouse_over(self):
|
|
201
|
+
"""True if the mouse is inside this widget (and not on a popup covering it)."""
|
|
202
|
+
x, y = self.mouse_pos()
|
|
203
|
+
if not (0 <= x < self.width and 0 <= y < self.height): return False
|
|
204
|
+
return self.view is None or self.view._layer_at(mouse.x, mouse.y) is self._popup
|
|
205
|
+
|
|
206
|
+
def content_size(self) -> tuple[Optional[int], Optional[int]]:
|
|
207
|
+
"""The size the content would like (width, height), None if it doesn't
|
|
208
|
+
matter. Popups use it to size themselves."""
|
|
209
|
+
return None, None
|
|
210
|
+
|
|
211
|
+
# --- Override these ---
|
|
212
|
+
def init(self): pass # set up attributes; the size isn't known yet
|
|
213
|
+
def draw(self, c: Canvas): pass # draw into c, which is exactly width x height
|
|
214
|
+
def on_input(self, input: Input): pass # key inputs only arrive while focused
|
|
215
|
+
def on_resize(self): pass # called after x, y, width, height change
|
|
216
|
+
def on_focus(self): pass
|
|
217
|
+
def on_blur(self): pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _annotations(cls):
|
|
221
|
+
try:
|
|
222
|
+
return inspect.get_annotations(cls)
|
|
223
|
+
except Exception:
|
|
224
|
+
return cls.__dict__.get("__annotations__", {})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _collect_fields(cls):
|
|
228
|
+
"""Work out a widget class's fields from its annotations (and its bases')."""
|
|
229
|
+
own = {}
|
|
230
|
+
for name, hint in _annotations(cls).items():
|
|
231
|
+
if name.startswith("_") or "ClassVar" in str(hint):
|
|
232
|
+
continue
|
|
233
|
+
value = cls.__dict__.get(name, _MISSING)
|
|
234
|
+
info = value if isinstance(value, _Field) else _Field(value, None, True, None)
|
|
235
|
+
own[name] = info
|
|
236
|
+
# Leave a plain default on the class, so `getattr(cls, name)` works and
|
|
237
|
+
# subclasses can override it by assignment
|
|
238
|
+
if info.default is not _MISSING:
|
|
239
|
+
setattr(cls, name, info.default)
|
|
240
|
+
elif name in cls.__dict__:
|
|
241
|
+
delattr(cls, name)
|
|
242
|
+
cls._own_fields = own
|
|
243
|
+
fields = {}
|
|
244
|
+
for klass in reversed(cls.__mro__):
|
|
245
|
+
fields.update(klass.__dict__.get("_own_fields", {}))
|
|
246
|
+
cls._fields = fields
|
|
247
|
+
|
|
248
|
+
_collect_fields(Widget)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _call(callback, *args):
|
|
252
|
+
if callback: callback(*args)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Text(Widget):
|
|
256
|
+
"""Text that wraps to fit."""
|
|
257
|
+
text: str = field(default="", kw_only=False)
|
|
258
|
+
align: Align = "left"
|
|
259
|
+
style: str = "" # escape code from style(), e.g. style(fg="red")
|
|
260
|
+
def draw(self, c):
|
|
261
|
+
pad = {"left": pad_right, "center": pad_center, "right": pad_left}[self.align]
|
|
262
|
+
for i, line in enumerate(wrap(str(self.text), c.width)[:c.height]):
|
|
263
|
+
c.text(0, i, pad(line, c.width), self.style or self.theme("text"))
|
|
264
|
+
def content_size(self):
|
|
265
|
+
lines = str(self.text).split("\n")
|
|
266
|
+
width = min(80, max(text_width(line) for line in lines))
|
|
267
|
+
return width, max(1, len(wrap(str(self.text), width)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Label(Text):
|
|
271
|
+
"""One line of text, cut off with … if too long."""
|
|
272
|
+
preferred_height = 1
|
|
273
|
+
def draw(self, c):
|
|
274
|
+
pad = {"left": pad_right, "center": pad_center, "right": pad_left}[self.align]
|
|
275
|
+
c.text(0, c.height // 2, pad(str(self.text), c.width), self.style or self.theme("text"))
|
|
276
|
+
def content_size(self):
|
|
277
|
+
return text_width(str(self.text)), 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class Button(Widget):
|
|
281
|
+
text: str = field(default="Button", kw_only=False)
|
|
282
|
+
callback: Optional[Callable[[], Any]] = field(default=None, alias="on_click")
|
|
283
|
+
preferred_width = "5+"
|
|
284
|
+
preferred_height = 1
|
|
285
|
+
focusable = True
|
|
286
|
+
def init(self):
|
|
287
|
+
self.hovered = False
|
|
288
|
+
def on_click(self, callback: Callable[[], Any]):
|
|
289
|
+
self.callback = callback
|
|
290
|
+
def on_input(self, input):
|
|
291
|
+
if(input.type == "key"):
|
|
292
|
+
if(input.details["key"] in ("enter", "space")): _call(self.callback)
|
|
293
|
+
return
|
|
294
|
+
if(not input.type.startswith("mouse")): return
|
|
295
|
+
|
|
296
|
+
if(self.mouse_over() != self.hovered):
|
|
297
|
+
self.hovered = self.mouse_over()
|
|
298
|
+
|
|
299
|
+
if(input.type == "mouse_down" and self.hovered):
|
|
300
|
+
_call(self.callback)
|
|
301
|
+
|
|
302
|
+
def draw(self, c):
|
|
303
|
+
mid = c.height // 2 # vertically centred
|
|
304
|
+
s = self.theme("button_hover" if self.hovered else "button_focus" if self.focused else "button")
|
|
305
|
+
c.text(0, mid, "[" + pad_center(self.text, c.width - 2) + "]", s)
|
|
306
|
+
def content_size(self):
|
|
307
|
+
return text_width(self.text) + 4, 1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TextInput(Widget):
|
|
311
|
+
"""A one-line text box. Click or Tab to it, then type."""
|
|
312
|
+
value: str = field(default="", kw_only=False)
|
|
313
|
+
placeholder: str = ""
|
|
314
|
+
submit_callback: Optional[Callable[[str], Any]] = field(default=None, alias="on_submit")
|
|
315
|
+
change_callback: Optional[Callable[[str], Any]] = field(default=None, alias="on_change")
|
|
316
|
+
preferred_width = "5+"
|
|
317
|
+
preferred_height = 1
|
|
318
|
+
border = True
|
|
319
|
+
focusable = True
|
|
320
|
+
captures_text = True
|
|
321
|
+
def init(self):
|
|
322
|
+
self.cursor = 0 # index in value
|
|
323
|
+
self.scroll = 0 # first visible character, when the value is longer than the box
|
|
324
|
+
def on_submit(self, callback: Callable[[str], Any]): # called with the value when Enter is pressed
|
|
325
|
+
self.submit_callback = callback
|
|
326
|
+
def on_change(self, callback: Callable[[str], Any]): # called with the value after every edit
|
|
327
|
+
self.change_callback = callback
|
|
328
|
+
|
|
329
|
+
def on_input(self, input):
|
|
330
|
+
if(input.type == "mouse_down" and self.mouse_over()):
|
|
331
|
+
self.cursor = min(len(self.value), self.scroll + self.mouse_pos()[0])
|
|
332
|
+
return
|
|
333
|
+
if(input.type != "key"): return
|
|
334
|
+
key, char = input.details["key"], input.details["char"]
|
|
335
|
+
value, cursor = self.value, self.cursor
|
|
336
|
+
if(char):
|
|
337
|
+
value = value[:cursor] + char + value[cursor:]
|
|
338
|
+
cursor += 1
|
|
339
|
+
elif(key == "backspace" and cursor > 0):
|
|
340
|
+
value = value[:cursor - 1] + value[cursor:]
|
|
341
|
+
cursor -= 1
|
|
342
|
+
elif(key == "delete"):
|
|
343
|
+
value = value[:cursor] + value[cursor + 1:]
|
|
344
|
+
elif(key == "left"): cursor = max(0, cursor - 1)
|
|
345
|
+
elif(key == "right"): cursor = min(len(value), cursor + 1)
|
|
346
|
+
elif(key in ("home", "ctrl+a")): cursor = 0
|
|
347
|
+
elif(key in ("end", "ctrl+e")): cursor = len(value)
|
|
348
|
+
elif(key == "enter"):
|
|
349
|
+
_call(self.submit_callback, self.value)
|
|
350
|
+
return
|
|
351
|
+
else:
|
|
352
|
+
return
|
|
353
|
+
changed = value != self.value
|
|
354
|
+
self.value, self.cursor = value, cursor
|
|
355
|
+
if(changed): _call(self.change_callback, self.value)
|
|
356
|
+
|
|
357
|
+
def draw(self, c):
|
|
358
|
+
self.cursor = max(0, min(self.cursor, len(self.value)))
|
|
359
|
+
# Keep the cursor in view
|
|
360
|
+
if(self.cursor < self.scroll): self._set_scroll(self.cursor)
|
|
361
|
+
if(self.cursor >= self.scroll + c.width): self._set_scroll(self.cursor - c.width + 1)
|
|
362
|
+
if(not self.value and not self.focused):
|
|
363
|
+
c.text(0, 0, fit(self.placeholder, c.width), self.theme("dim"))
|
|
364
|
+
return
|
|
365
|
+
c.text(0, 0, self.value[self.scroll:self.scroll + c.width])
|
|
366
|
+
if(self.focused):
|
|
367
|
+
x = self.cursor - self.scroll
|
|
368
|
+
char = self.value[self.cursor] if self.cursor < len(self.value) else " "
|
|
369
|
+
c.put(x, 0, char, self.theme("cursor"))
|
|
370
|
+
def _set_scroll(self, value):
|
|
371
|
+
object.__setattr__(self, "scroll", value) # no redraw, we're already drawing
|
|
372
|
+
def content_size(self):
|
|
373
|
+
return max(20, text_width(self.placeholder) + 1), 1
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ProgressBar(Widget):
|
|
377
|
+
value: float = field(default=0.0, kw_only=False) # 0 to 1
|
|
378
|
+
show_percent: bool = True
|
|
379
|
+
preferred_height = 1
|
|
380
|
+
def draw(self, c):
|
|
381
|
+
value = max(0.0, min(1.0, float(self.value)))
|
|
382
|
+
label = f" {round(value * 100):>3}%" if self.show_percent else ""
|
|
383
|
+
bar = max(0, c.width - len(label))
|
|
384
|
+
filled = round(value * bar)
|
|
385
|
+
mid = c.height // 2
|
|
386
|
+
c.text(0, mid, "█" * filled, self.theme("progress"))
|
|
387
|
+
c.text(filled, mid, "░" * (bar - filled), self.theme("progress_empty"))
|
|
388
|
+
c.text(bar, mid, label)
|
|
389
|
+
def content_size(self):
|
|
390
|
+
return 20, 1
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class Checkbox(Widget):
|
|
394
|
+
text: str = field(default="", kw_only=False)
|
|
395
|
+
checked: bool = False
|
|
396
|
+
change_callback: Optional[Callable[[bool], Any]] = field(default=None, alias="on_change")
|
|
397
|
+
preferred_width = "5+"
|
|
398
|
+
preferred_height = 1
|
|
399
|
+
focusable = True
|
|
400
|
+
def on_change(self, callback: Callable[[bool], Any]): # called with True/False
|
|
401
|
+
self.change_callback = callback
|
|
402
|
+
def toggle(self):
|
|
403
|
+
self.checked = not self.checked
|
|
404
|
+
_call(self.change_callback, self.checked)
|
|
405
|
+
def on_input(self, input):
|
|
406
|
+
if(input.type == "mouse_down" and self.mouse_over()): self.toggle()
|
|
407
|
+
if(input.type == "key" and input.details["key"] in ("enter", "space")): self.toggle()
|
|
408
|
+
def draw(self, c):
|
|
409
|
+
s = self.theme("button_focus") if self.focused else ""
|
|
410
|
+
c.text(0, c.height // 2, fit(("[x] " if self.checked else "[ ] ") + self.text, c.width), s)
|
|
411
|
+
def content_size(self):
|
|
412
|
+
return text_width(self.text) + 5, 1 # one space after, so neighbours don't touch
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class Toggle(Checkbox):
|
|
416
|
+
"""An on/off switch. Same as Checkbox, drawn differently."""
|
|
417
|
+
def draw(self, c):
|
|
418
|
+
mid = c.height // 2
|
|
419
|
+
x = c.text(0, mid, " ON " if self.checked else " OFF ", self.theme("on" if self.checked else "off"))
|
|
420
|
+
c.text(x + 1, mid, fit(self.text, c.width - x - 1), self.theme("button_focus") if self.focused else "")
|
|
421
|
+
def content_size(self):
|
|
422
|
+
return text_width(self.text) + 7, 1
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class Menu(Widget):
|
|
426
|
+
"""A list to pick from with the arrow keys and Enter, or a click."""
|
|
427
|
+
items: list = field(default_factory=list, kw_only=False)
|
|
428
|
+
selected: int = 0
|
|
429
|
+
select_callback: Optional[Callable[[int, Any], Any]] = field(default=None, alias="on_select")
|
|
430
|
+
border = True
|
|
431
|
+
focusable = True
|
|
432
|
+
def init(self):
|
|
433
|
+
self.scroll = 0
|
|
434
|
+
self.hovered = None
|
|
435
|
+
def on_select(self, callback: Callable[[int, Any], Any]): # called with (index, item) on Enter or click
|
|
436
|
+
self.select_callback = callback
|
|
437
|
+
def _choose(self):
|
|
438
|
+
if(0 <= self.selected < len(self.items)):
|
|
439
|
+
_call(self.select_callback, self.selected, self.items[self.selected])
|
|
440
|
+
|
|
441
|
+
def on_input(self, input):
|
|
442
|
+
if(input.type == "key"):
|
|
443
|
+
key = input.details["key"]
|
|
444
|
+
moves = {"up": -1, "down": 1, "page_up": -self.height, "page_down": self.height,
|
|
445
|
+
"home": -len(self.items), "end": len(self.items)}
|
|
446
|
+
if(key in moves and self.items):
|
|
447
|
+
self.selected = max(0, min(len(self.items) - 1, self.selected + moves[key]))
|
|
448
|
+
elif(key in ("enter", "space")):
|
|
449
|
+
self._choose()
|
|
450
|
+
elif(input.type == "mouse_down" and self.mouse_over()):
|
|
451
|
+
index = self.scroll + self.mouse_pos()[1]
|
|
452
|
+
if(index < len(self.items)):
|
|
453
|
+
self.selected = index
|
|
454
|
+
self._choose()
|
|
455
|
+
elif(input.type == "mouse_scroll" and self.mouse_over()):
|
|
456
|
+
step = -1 if input.details["direction"] == "up" else 1
|
|
457
|
+
self.scroll = max(0, min(max(0, len(self.items) - self.height), self.scroll + step))
|
|
458
|
+
elif(input.type == "mouse_move"):
|
|
459
|
+
self.hovered = None
|
|
460
|
+
if(not self.mouse_over()): return
|
|
461
|
+
index = self.scroll + self.mouse_pos()[1]
|
|
462
|
+
if(index < len(self.items)):
|
|
463
|
+
self.hovered = index
|
|
464
|
+
def draw(self, c):
|
|
465
|
+
# Keep the selection in view
|
|
466
|
+
if(self.selected < self.scroll): object.__setattr__(self, "scroll", self.selected)
|
|
467
|
+
if(self.selected >= self.scroll + c.height): object.__setattr__(self, "scroll", self.selected - c.height + 1)
|
|
468
|
+
for row, item in enumerate(self.items[self.scroll:self.scroll + c.height]):
|
|
469
|
+
index = self.scroll + row
|
|
470
|
+
s = ""
|
|
471
|
+
if(index == self.selected):
|
|
472
|
+
s = self.theme("selected" if self.focused else "selected_unfocused")
|
|
473
|
+
elif(index == self.hovered):
|
|
474
|
+
pass#s = self.theme("button_hover")
|
|
475
|
+
c.text(0, row, pad_right(" " + str(item), c.width), s)
|
|
476
|
+
def content_size(self):
|
|
477
|
+
width = max([text_width(str(item)) for item in self.items] + [6])
|
|
478
|
+
return width + 2, max(1, min(len(self.items), 10))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class Table(Widget):
|
|
482
|
+
columns: list = field(default_factory=list, kw_only=False) # header names
|
|
483
|
+
rows: list = field(default_factory=list) # lists of values
|
|
484
|
+
border = True
|
|
485
|
+
def init(self):
|
|
486
|
+
self.scroll = 0
|
|
487
|
+
def on_input(self, input):
|
|
488
|
+
if(input.type == "mouse_scroll" and self.mouse_over()):
|
|
489
|
+
step = -1 if input.details["direction"] == "up" else 1
|
|
490
|
+
self.scroll = max(0, min(max(0, len(self.rows) - (self.height - 1)), self.scroll + step))
|
|
491
|
+
def draw(self, c):
|
|
492
|
+
cells = [[str(v) for v in row] for row in self.rows]
|
|
493
|
+
count = len(self.columns)
|
|
494
|
+
if(not count): return
|
|
495
|
+
widths = [max([text_width(self.columns[i])] + [text_width(r[i]) for r in cells if i < len(r)]) for i in range(count)]
|
|
496
|
+
# Shrink the widest columns until it fits, 2 spaces between columns
|
|
497
|
+
while(sum(widths) + 2 * (count - 1) > c.width and max(widths) > 1):
|
|
498
|
+
widths[widths.index(max(widths))] -= 1
|
|
499
|
+
def line(y, values, s):
|
|
500
|
+
x = 0
|
|
501
|
+
for i in range(count):
|
|
502
|
+
c.text(x, y, pad_right(values[i] if i < len(values) else "", widths[i]), s)
|
|
503
|
+
x += widths[i] + 2
|
|
504
|
+
line(0, self.columns, self.theme("header"))
|
|
505
|
+
for row, values in enumerate(cells[self.scroll:self.scroll + c.height - 1]):
|
|
506
|
+
line(row + 1, values, "")
|
|
507
|
+
def content_size(self):
|
|
508
|
+
cells = [[str(v) for v in row] for row in self.rows]
|
|
509
|
+
widths = [max([text_width(str(col))] + [text_width(r[i]) for r in cells if i < len(r)])
|
|
510
|
+
for i, col in enumerate(self.columns)]
|
|
511
|
+
return min(80, sum(widths) + 2 * max(0, len(widths) - 1)), min(len(self.rows) + 1, 12)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class Stdout(Widget):
|
|
515
|
+
"""Shows everything printed (and stderr, in red). Scroll with the mouse wheel."""
|
|
516
|
+
max_lines: int = 500
|
|
517
|
+
border = True
|
|
518
|
+
preferred_width = "10+"
|
|
519
|
+
preferred_height = "3+"
|
|
520
|
+
def init(self):
|
|
521
|
+
self.lines = [["", ""]] # [text, style]
|
|
522
|
+
self.scroll = 0 # wrapped lines up from the bottom
|
|
523
|
+
def on_input(self, input):
|
|
524
|
+
if(input.type in ("stdout", "stderr")):
|
|
525
|
+
s = self.theme("error") if input.type == "stderr" else ""
|
|
526
|
+
# Text can arrive mid-line, so the first part continues the last line
|
|
527
|
+
parts = input.details["text"].split("\n")
|
|
528
|
+
self.lines[-1][0] += parts[0]
|
|
529
|
+
if(parts[0] and s): self.lines[-1][1] = s
|
|
530
|
+
self.lines.extend([part, s] for part in parts[1:])
|
|
531
|
+
del self.lines[:-self.max_lines]
|
|
532
|
+
if(self.scroll): self._set_scroll(self.scroll + len(parts) - 1) # stay put while scrolled up
|
|
533
|
+
self.refresh()
|
|
534
|
+
elif(input.type == "mouse_scroll" and self.mouse_over()):
|
|
535
|
+
step = 1 if input.details["direction"] == "up" else -1
|
|
536
|
+
self._set_scroll(max(0, self.scroll + step))
|
|
537
|
+
self.refresh()
|
|
538
|
+
def _set_scroll(self, value):
|
|
539
|
+
object.__setattr__(self, "scroll", value)
|
|
540
|
+
def clear(self):
|
|
541
|
+
"""Remove everything shown so far."""
|
|
542
|
+
self.lines = [["", ""]]
|
|
543
|
+
self._set_scroll(0)
|
|
544
|
+
def content_size(self):
|
|
545
|
+
return 40, 8
|
|
546
|
+
def draw(self, c):
|
|
547
|
+
lines = self.lines[:-1] if self.lines[-1][0] == "" else self.lines # skip empty line after a trailing \n
|
|
548
|
+
wrapped = [(part, s) for text, s in lines for part in wrap(text, c.width)]
|
|
549
|
+
self._set_scroll(min(self.scroll, max(0, len(wrapped) - c.height)))
|
|
550
|
+
end = len(wrapped) - self.scroll
|
|
551
|
+
for i, (line, s) in enumerate(wrapped[max(0, end - c.height):end]):
|
|
552
|
+
c.text(0, i, line, s)
|
|
553
|
+
if(self.scroll):
|
|
554
|
+
label = f" ↓ {self.scroll} more "
|
|
555
|
+
c.text(max(0, c.width - len(label)), c.height - 1, label, self.theme("dim"))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class DrawMouse(Widget):
|
|
559
|
+
border = True
|
|
560
|
+
def init(self):
|
|
561
|
+
self.tiles:dict[tuple[int,int], bool] = {}
|
|
562
|
+
def on_input(self, input):
|
|
563
|
+
if(not input.type.startswith("mouse")): return
|
|
564
|
+
|
|
565
|
+
# Toggle a tile on click, and on each new cell while dragging
|
|
566
|
+
if(self.mouse_over() and (input.type == "mouse_down" or (mouse.is_down() and mouse.moved))):
|
|
567
|
+
pos = self.mouse_pos()
|
|
568
|
+
if(self.tiles.get(pos)): self.tiles.pop(pos)
|
|
569
|
+
else: self.tiles[pos] = True
|
|
570
|
+
|
|
571
|
+
self.refresh()
|
|
572
|
+
def draw(self, c):
|
|
573
|
+
for key, _ in self.tiles.items():
|
|
574
|
+
x,y = key
|
|
575
|
+
c.put(x, y, "#")
|
|
576
|
+
x, y = self.mouse_pos()
|
|
577
|
+
c.put(x, y, "X" if mouse.is_down() else "O")
|