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/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")