zettcode-tui 0.4.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.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
zettcode/tui/core/app.py
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""The application runtime: screens, focus, routing, layout, and painting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from time import monotonic
|
|
9
|
+
|
|
10
|
+
from ..render import Canvas, Style
|
|
11
|
+
from .events import AnyEvent, MouseAction, MouseEvent, ResizeEvent
|
|
12
|
+
from .focus import FocusManager, walk
|
|
13
|
+
from .geometry import Point, Rect
|
|
14
|
+
from .host import Host
|
|
15
|
+
from .keymap import CommandRegistry, Keymap
|
|
16
|
+
from .scheduler import Scheduler
|
|
17
|
+
from .screen import Screen, ScreenStack
|
|
18
|
+
from .theme import DARK, Theme
|
|
19
|
+
from .widget import Widget
|
|
20
|
+
|
|
21
|
+
#: Seconds a caller waits for its next frame before treating the frame as missed.
|
|
22
|
+
#: A runner normally paints in milliseconds; the bound is insurance against a
|
|
23
|
+
#: future paint path that forgets to report one, which would wedge a command.
|
|
24
|
+
FRAME_WAIT = 5.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TuiApp(Host):
|
|
28
|
+
"""Own one widget tree and everything that drives it.
|
|
29
|
+
|
|
30
|
+
It is the only :class:`~zettcode.tui.core.host.Host`, so widgets
|
|
31
|
+
reach the outside world only through it. It never touches a terminal:
|
|
32
|
+
callers hand it a size and paint the returned canvas, which is what lets
|
|
33
|
+
the same tree run under a real TTY or a headless test.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
root: Widget,
|
|
39
|
+
*,
|
|
40
|
+
width: int = 80,
|
|
41
|
+
height: int = 24,
|
|
42
|
+
theme: Theme | None = None,
|
|
43
|
+
title: str | Callable[[], str] = "",
|
|
44
|
+
auto_theme: bool = False,
|
|
45
|
+
clock: Callable[[], float] = monotonic,
|
|
46
|
+
max_fps: float = 120.0,
|
|
47
|
+
keymap: Keymap | None = None,
|
|
48
|
+
commands: CommandRegistry | None = None,
|
|
49
|
+
on_copy: Callable[[str], None] | None = None,
|
|
50
|
+
reduced_motion: bool = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Build an app around ``root`` at the given size, theme, and frame budget.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
root: Widget at the base of the tree; it is wrapped in a ``"main"``
|
|
56
|
+
screen, so callers never build that layer themselves.
|
|
57
|
+
width: Initial width in cells, floored at 1; a real terminal
|
|
58
|
+
overwrites it through ``resize``.
|
|
59
|
+
height: Initial height in cells, floored at 1.
|
|
60
|
+
theme: Active palette; defaults to ``DARK``.
|
|
61
|
+
title: Text the terminal shows in its window or tab name; a callable
|
|
62
|
+
is read once per frame, so a title that changes with the session
|
|
63
|
+
needs no extra plumbing
|
|
64
|
+
auto_theme: Let the runner replace the palette with the one the
|
|
65
|
+
terminal's own background asks for, once it can ask
|
|
66
|
+
clock: Time source shared with the scheduler and the widgets.
|
|
67
|
+
max_fps: Ceiling for animation frames, enforced by the scheduler.
|
|
68
|
+
keymap: Bindings to use; a fresh empty one is created when omitted.
|
|
69
|
+
commands: Commands the bindings may name; same default as ``keymap``.
|
|
70
|
+
on_copy: Sink that mirrors copied text to the system clipboard.
|
|
71
|
+
reduced_motion: Suppress decorative animation for the whole tree.
|
|
72
|
+
"""
|
|
73
|
+
self.root = root
|
|
74
|
+
self.width = max(1, width)
|
|
75
|
+
self.height = max(1, height)
|
|
76
|
+
self._theme = theme or DARK
|
|
77
|
+
self.title = title
|
|
78
|
+
self.auto_theme = auto_theme
|
|
79
|
+
self.clock = clock
|
|
80
|
+
self.max_fps = max_fps
|
|
81
|
+
self.keymap = keymap or Keymap()
|
|
82
|
+
self.commands = commands or CommandRegistry()
|
|
83
|
+
self.focus_manager = FocusManager()
|
|
84
|
+
self.scheduler = Scheduler(max_fps=max_fps, clock=clock)
|
|
85
|
+
self._main = Screen(root, name="main")
|
|
86
|
+
self.screens = ScreenStack(self._main)
|
|
87
|
+
self.clipboard = ""
|
|
88
|
+
self.refreshed = False
|
|
89
|
+
self.running = True
|
|
90
|
+
self.reduced_motion = reduced_motion
|
|
91
|
+
self._on_copy = on_copy
|
|
92
|
+
self._mouse_capture: Widget | None = None
|
|
93
|
+
self._frame: Canvas | None = None
|
|
94
|
+
self._select_anchor: tuple[int, int] | None = None
|
|
95
|
+
self._select_head: tuple[int, int] | None = None
|
|
96
|
+
self._layout_dirty = True
|
|
97
|
+
self._mounted = False
|
|
98
|
+
#: Whether a runner is painting this app. A headless tree has no frame to
|
|
99
|
+
#: wait for, so a caller that wants one is not left waiting forever.
|
|
100
|
+
self._painting = False
|
|
101
|
+
#: Callers waiting for the next frame to reach the terminal. The runner
|
|
102
|
+
#: resolves them after it writes the frame, not when it starts drawing it.
|
|
103
|
+
self._frame_waiters: list[asyncio.Future[None]] = []
|
|
104
|
+
#: Watchers of every event that arrives, before any widget sees it. A resize or a focus report has no widget
|
|
105
|
+
#: target of its own, so this is where an application learns about it.
|
|
106
|
+
self._listeners: list[Callable[[AnyEvent], None]] = []
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def theme(self) -> Theme:
|
|
110
|
+
"""Return the palette every widget reads while painting."""
|
|
111
|
+
return self._theme
|
|
112
|
+
|
|
113
|
+
@theme.setter
|
|
114
|
+
def theme(self, value: Theme) -> None:
|
|
115
|
+
"""Adopt a new palette, forcing the next frame to repaint from scratch.
|
|
116
|
+
|
|
117
|
+
Widgets read ``theme`` at paint time, but their caches and the
|
|
118
|
+
differential renderer may still hold the previous colours; dropping the
|
|
119
|
+
previous frame is what guarantees a switch cannot leave half the screen
|
|
120
|
+
in the old palette.
|
|
121
|
+
"""
|
|
122
|
+
if value == self._theme:
|
|
123
|
+
return
|
|
124
|
+
self._theme = value
|
|
125
|
+
self.refreshed = True
|
|
126
|
+
self.scheduler.request_repaint()
|
|
127
|
+
|
|
128
|
+
# -- lifecycle ----------------------------------------------------------
|
|
129
|
+
def mount(self) -> None:
|
|
130
|
+
"""Attach every screen and give each widget a reference back to the app."""
|
|
131
|
+
if self._mounted:
|
|
132
|
+
return
|
|
133
|
+
for screen in self.screens:
|
|
134
|
+
self._attach(screen.widget)
|
|
135
|
+
screen.widget.mount()
|
|
136
|
+
self._mounted = True
|
|
137
|
+
if self.focus_manager.focused is None:
|
|
138
|
+
candidates = self.focus_manager.focusables(self.screens.top.widget)
|
|
139
|
+
if candidates:
|
|
140
|
+
self.focus_manager.focus(candidates[0])
|
|
141
|
+
self.request_layout()
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
"""Detach every screen, running the unmount hooks once."""
|
|
145
|
+
if not self._mounted:
|
|
146
|
+
return
|
|
147
|
+
for screen in self.screens:
|
|
148
|
+
screen.widget.unmount()
|
|
149
|
+
self._mounted = False
|
|
150
|
+
|
|
151
|
+
def set_root(self, root: Widget) -> None:
|
|
152
|
+
"""Replace the base widget, mounting the new tree where the old one stood.
|
|
153
|
+
|
|
154
|
+
A shell can put something cheap up first — the composer, while the
|
|
155
|
+
application's own modules are still loading — and then hand this running
|
|
156
|
+
app the real tree. Focus moves into the new tree and every cell is
|
|
157
|
+
repainted, so a swap cannot leave a mixture of the two on screen.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
root: Widget to install as the base of the ``"main"`` screen.
|
|
161
|
+
"""
|
|
162
|
+
previous = self._main.widget
|
|
163
|
+
self.root = root
|
|
164
|
+
self._main.widget = root
|
|
165
|
+
if not self._mounted:
|
|
166
|
+
return
|
|
167
|
+
self._attach(root)
|
|
168
|
+
root.mount()
|
|
169
|
+
if previous is not None:
|
|
170
|
+
previous.unmount()
|
|
171
|
+
candidates = self.focus_manager.focusables(root)
|
|
172
|
+
self.focus_manager.focus(candidates[0] if candidates else None)
|
|
173
|
+
self.refresh()
|
|
174
|
+
|
|
175
|
+
def _attach(self, widget: Widget) -> None:
|
|
176
|
+
"""Give every widget in the subtree a back reference to this app."""
|
|
177
|
+
widget.app = self
|
|
178
|
+
for child in widget.children:
|
|
179
|
+
self._attach(child)
|
|
180
|
+
|
|
181
|
+
# -- Host ---------------------------------------------------------------
|
|
182
|
+
def focus(self, widget: Widget | None) -> None:
|
|
183
|
+
"""Move focus and repaint, running the blur and focus hooks.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
widget: New focus target, or ``None`` to clear focus.
|
|
187
|
+
"""
|
|
188
|
+
self.focus_manager.focus(widget)
|
|
189
|
+
self.invalidate()
|
|
190
|
+
|
|
191
|
+
def focused_widget(self) -> Widget | None:
|
|
192
|
+
"""Return the widget that receives keyboard input, if any."""
|
|
193
|
+
return self.focus_manager.focused
|
|
194
|
+
|
|
195
|
+
def add_listener(self, listener: Callable[[AnyEvent], None]) -> Callable[[AnyEvent], None]:
|
|
196
|
+
"""Call ``listener`` with every event that arrives, before routing.
|
|
197
|
+
|
|
198
|
+
The listener cannot consume an event; it observes. It is how an application hears about a ``FocusEvent`` or a
|
|
199
|
+
``ResizeEvent``, which are about the window and not about the widget under the pointer.
|
|
200
|
+
"""
|
|
201
|
+
self._listeners.append(listener)
|
|
202
|
+
return listener
|
|
203
|
+
|
|
204
|
+
def invalidate(self) -> None:
|
|
205
|
+
"""Request a repaint on the next frame."""
|
|
206
|
+
self.scheduler.request_repaint()
|
|
207
|
+
|
|
208
|
+
def begin_painting(self) -> None:
|
|
209
|
+
"""Mark the app as driven by a runner that will report painted frames.
|
|
210
|
+
|
|
211
|
+
A headless tree, and the boot tree before its runner starts, has nothing
|
|
212
|
+
painting it: :meth:`wait_for_frame` returns after one scheduling turn
|
|
213
|
+
instead of waiting for a frame that cannot come.
|
|
214
|
+
"""
|
|
215
|
+
self._painting = True
|
|
216
|
+
|
|
217
|
+
def end_painting(self) -> None:
|
|
218
|
+
"""Stop waiting for a runner and release callers that were waiting on one."""
|
|
219
|
+
self._painting = False
|
|
220
|
+
waiters, self._frame_waiters = self._frame_waiters, []
|
|
221
|
+
for waiter in waiters:
|
|
222
|
+
if not waiter.done():
|
|
223
|
+
waiter.set_result(None)
|
|
224
|
+
|
|
225
|
+
def frame_painted(self) -> None:
|
|
226
|
+
"""Release everyone waiting for the frame the runner has just written."""
|
|
227
|
+
waiters, self._frame_waiters = self._frame_waiters, []
|
|
228
|
+
for waiter in waiters:
|
|
229
|
+
if not waiter.done():
|
|
230
|
+
waiter.set_result(None)
|
|
231
|
+
|
|
232
|
+
async def wait_for_frame(self) -> None:
|
|
233
|
+
"""Wait until the next frame has reached the terminal.
|
|
234
|
+
|
|
235
|
+
A command that is about to do work with no ``await`` in it asks for this
|
|
236
|
+
first: the row saying what it is doing is in the model, and this gives
|
|
237
|
+
the runner the turn it needs to draw that row before the work starts.
|
|
238
|
+
The repaint request here is required, not a nudge: changing the
|
|
239
|
+
transcript bumps its version but does not invalidate the app itself.
|
|
240
|
+
With no runner painting the tree there is no frame to wait for, so one
|
|
241
|
+
scheduling turn is the whole wait. A frame that never arrives is a
|
|
242
|
+
missed frame, not a reason to leave a command wedged with its spinner up.
|
|
243
|
+
"""
|
|
244
|
+
if not self._painting or not self.running:
|
|
245
|
+
await asyncio.sleep(0)
|
|
246
|
+
return
|
|
247
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
248
|
+
self._frame_waiters.append(waiter)
|
|
249
|
+
self.scheduler.request_repaint()
|
|
250
|
+
try:
|
|
251
|
+
await asyncio.wait_for(waiter, FRAME_WAIT)
|
|
252
|
+
except asyncio.TimeoutError:
|
|
253
|
+
pass
|
|
254
|
+
finally:
|
|
255
|
+
if waiter in self._frame_waiters:
|
|
256
|
+
self._frame_waiters.remove(waiter)
|
|
257
|
+
|
|
258
|
+
def request_layout(self) -> None:
|
|
259
|
+
"""Mark the geometry stale so the next paint measures the tree again."""
|
|
260
|
+
self._layout_dirty = True
|
|
261
|
+
self.scheduler.request_repaint()
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def layout_pending(self) -> bool:
|
|
265
|
+
"""Return whether the next frame must re-measure the widget tree."""
|
|
266
|
+
return self._layout_dirty
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def waiting_for_frame(self) -> bool:
|
|
270
|
+
"""Return whether a caller is waiting for the next frame to be written."""
|
|
271
|
+
return bool(self._frame_waiters)
|
|
272
|
+
|
|
273
|
+
def copy(self, text: str) -> None:
|
|
274
|
+
"""Store text on the clipboard and mirror it to the copy handler.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
text: Text to copy; an empty string is ignored so a stray copy never
|
|
278
|
+
wipes the clipboard.
|
|
279
|
+
"""
|
|
280
|
+
if not text:
|
|
281
|
+
return
|
|
282
|
+
self.clipboard = text
|
|
283
|
+
if self._on_copy is not None:
|
|
284
|
+
self._on_copy(text)
|
|
285
|
+
|
|
286
|
+
def set_copy_handler(self, handler: Callable[[str], None] | None) -> None:
|
|
287
|
+
"""Add a sink for copied text, keeping the sinks already installed."""
|
|
288
|
+
if handler is None or self._on_copy is None or self._on_copy is handler:
|
|
289
|
+
self._on_copy = handler
|
|
290
|
+
return
|
|
291
|
+
# Handlers chain in the order they were installed: the runner installs the
|
|
292
|
+
# one that reaches the system clipboard, and a shell that wants to say what
|
|
293
|
+
# was copied installs its own — neither has to know about the other, and
|
|
294
|
+
# whichever installs last does not take the clipboard away from the first.
|
|
295
|
+
previous = self._on_copy
|
|
296
|
+
|
|
297
|
+
def chained(text: str) -> None:
|
|
298
|
+
previous(text)
|
|
299
|
+
handler(text)
|
|
300
|
+
|
|
301
|
+
self._on_copy = chained
|
|
302
|
+
|
|
303
|
+
def refresh(self) -> None:
|
|
304
|
+
"""Repaint from scratch, discarding the renderer's previous frame."""
|
|
305
|
+
self.refreshed = True
|
|
306
|
+
self.request_layout()
|
|
307
|
+
|
|
308
|
+
def exit(self) -> None:
|
|
309
|
+
"""Ask the terminal loop to stop after the current frame."""
|
|
310
|
+
self.running = False
|
|
311
|
+
self.invalidate()
|
|
312
|
+
|
|
313
|
+
# -- screens ------------------------------------------------------------
|
|
314
|
+
def push_screen(self, screen: Screen) -> Screen:
|
|
315
|
+
"""Stack a layer on top and move focus into it.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
screen: Layer to stack; when it holds no focusable widget the layer
|
|
319
|
+
below keeps the keyboard focus.
|
|
320
|
+
"""
|
|
321
|
+
self.screens.top.remembered_focus = self.focus_manager.focused
|
|
322
|
+
self.screens.push(screen)
|
|
323
|
+
if self._mounted:
|
|
324
|
+
self._attach(screen.widget)
|
|
325
|
+
screen.widget.mount()
|
|
326
|
+
candidates = self.focus_manager.focusables(screen.widget)
|
|
327
|
+
if candidates:
|
|
328
|
+
# A screen without focusable widgets (a toast, a hint) must not steal
|
|
329
|
+
# focus from whatever the user was already typing into.
|
|
330
|
+
self.focus_manager.focus(candidates[0])
|
|
331
|
+
self.request_layout()
|
|
332
|
+
return screen
|
|
333
|
+
|
|
334
|
+
def pop_screen(self) -> Screen | None:
|
|
335
|
+
"""Remove the topmost layer and restore the focus it covered."""
|
|
336
|
+
screen = self.screens.pop()
|
|
337
|
+
if screen is None:
|
|
338
|
+
return None
|
|
339
|
+
if self._mounted:
|
|
340
|
+
screen.widget.unmount()
|
|
341
|
+
restored = self.screens.top
|
|
342
|
+
self.focus_manager.focus(restored.remembered_focus or self.focus_manager.first(restored.widget))
|
|
343
|
+
self.request_layout()
|
|
344
|
+
return screen
|
|
345
|
+
|
|
346
|
+
def focus_next(self, direction: int = 1) -> Widget | None:
|
|
347
|
+
"""Move focus to the next or previous Tab stop in the active scope.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
direction: Positive moves forward, negative moves backward; the
|
|
351
|
+
scope is the top screen, so a modal layer traps Tab.
|
|
352
|
+
"""
|
|
353
|
+
widget = self.focus_manager.move(self.screens.top.widget, direction)
|
|
354
|
+
self.invalidate()
|
|
355
|
+
return widget
|
|
356
|
+
|
|
357
|
+
# -- geometry -----------------------------------------------------------
|
|
358
|
+
def resize(self, width: int, height: int) -> None:
|
|
359
|
+
"""Adopt a new terminal size and re-measure the tree.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
width: New width in cells, floored at 1.
|
|
363
|
+
height: New height in cells, floored at 1.
|
|
364
|
+
"""
|
|
365
|
+
self.width = max(1, width)
|
|
366
|
+
self.height = max(1, height)
|
|
367
|
+
self.request_layout()
|
|
368
|
+
|
|
369
|
+
def layout(self) -> None:
|
|
370
|
+
"""Assign rectangles for every screen at the current size."""
|
|
371
|
+
self._ensure_mounted()
|
|
372
|
+
rect = Rect(0, 0, self.width, self.height)
|
|
373
|
+
for screen in self.screens:
|
|
374
|
+
screen.widget.layout(rect)
|
|
375
|
+
self._layout_dirty = False
|
|
376
|
+
|
|
377
|
+
def render(self) -> Canvas:
|
|
378
|
+
"""Paint every screen in order into one fresh canvas.
|
|
379
|
+
|
|
380
|
+
The canvas starts filled with the palette's page colour when it names
|
|
381
|
+
one, because a light palette has to paint its own page: the terminal's
|
|
382
|
+
background is somebody else's dark. A palette with no page colour —
|
|
383
|
+
the dark one — leaves those cells to the terminal, so only the widgets
|
|
384
|
+
that draw a surface of their own change what is underneath.
|
|
385
|
+
"""
|
|
386
|
+
if self._layout_dirty:
|
|
387
|
+
self.layout()
|
|
388
|
+
canvas = Canvas(self.width, self.height)
|
|
389
|
+
if self.theme.background is not None:
|
|
390
|
+
canvas.fill(0, 0, self.width, self.height, Style(background=self.theme.background))
|
|
391
|
+
for screen in self.screens:
|
|
392
|
+
screen.widget.render(canvas)
|
|
393
|
+
self._frame = canvas
|
|
394
|
+
self._paint_selection(canvas)
|
|
395
|
+
return canvas
|
|
396
|
+
|
|
397
|
+
def _paint_selection(self, canvas: Canvas) -> None:
|
|
398
|
+
"""Mark the cells a screen selection covers, leaving their glyphs alone."""
|
|
399
|
+
if self._select_anchor is None or self._select_head is None:
|
|
400
|
+
return
|
|
401
|
+
(first_x, first_y), (last_x, last_y) = sorted((self._select_anchor, self._select_head))
|
|
402
|
+
for y in range(first_y, last_y + 1):
|
|
403
|
+
left = first_x if y == first_y else 0
|
|
404
|
+
right = last_x if y == last_y else canvas.width
|
|
405
|
+
if right > left:
|
|
406
|
+
canvas.restyle(left, y, right - left, _invert)
|
|
407
|
+
|
|
408
|
+
def cursor(self) -> Point | None:
|
|
409
|
+
"""Return the cursor the focused widget wants, if any."""
|
|
410
|
+
focused = self.focus_manager.focused
|
|
411
|
+
if focused is not None:
|
|
412
|
+
return focused.cursor()
|
|
413
|
+
return self.screens.top.widget.cursor()
|
|
414
|
+
|
|
415
|
+
def tick(self) -> bool:
|
|
416
|
+
"""Give every mounted widget a chance to react to the clock.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Whether any widget may have changed what is on screen. Every widget is asked,
|
|
420
|
+
whatever the first one said: each reacts to the clock whether or not another did.
|
|
421
|
+
"""
|
|
422
|
+
changed = False
|
|
423
|
+
for screen in list(self.screens):
|
|
424
|
+
for widget in walk(screen.widget):
|
|
425
|
+
if widget.on_tick() is not False:
|
|
426
|
+
changed = True
|
|
427
|
+
return changed
|
|
428
|
+
|
|
429
|
+
# -- input --------------------------------------------------------------
|
|
430
|
+
def dispatch(self, event: AnyEvent) -> bool:
|
|
431
|
+
"""Route one event through capture, keymap, target, and bubble phases.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
event: Input to route; a resize is applied directly instead of being
|
|
435
|
+
delivered to the tree.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Whether some widget or binding consumed the event.
|
|
439
|
+
"""
|
|
440
|
+
if self._layout_dirty:
|
|
441
|
+
self.layout()
|
|
442
|
+
for listener in self._listeners:
|
|
443
|
+
try:
|
|
444
|
+
listener(event)
|
|
445
|
+
except Exception: # a watcher is not allowed to take the input path down with it
|
|
446
|
+
continue
|
|
447
|
+
if isinstance(event, ResizeEvent):
|
|
448
|
+
self.resize(event.width or self.width, event.height or self.height)
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
target = self._target(event)
|
|
452
|
+
if target is None:
|
|
453
|
+
return False
|
|
454
|
+
path = self._path(target)
|
|
455
|
+
|
|
456
|
+
if isinstance(event, MouseEvent):
|
|
457
|
+
self._track_mouse(event, target)
|
|
458
|
+
self._track_selection(event, path)
|
|
459
|
+
|
|
460
|
+
for widget in path:
|
|
461
|
+
if widget.capture_event(event, self):
|
|
462
|
+
self.invalidate()
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
if self._run_binding(event, "capture"):
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
if target.handle(event, self):
|
|
469
|
+
self.invalidate()
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
if self._run_binding(event, "bubble"):
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
for widget in reversed(path[:-1]):
|
|
476
|
+
if widget.bubble_event(event, self):
|
|
477
|
+
self.invalidate()
|
|
478
|
+
return True
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
def _track_mouse(self, event: MouseEvent, target: Widget) -> None:
|
|
482
|
+
"""Keep a drag with the widget that started it, until the button is up.
|
|
483
|
+
|
|
484
|
+
A pointer that leaves the widget mid-drag — over the composer, past the
|
|
485
|
+
edge of a list — would otherwise hand every later movement, and the
|
|
486
|
+
release, to whatever is under it: the drag stops extending and the copy
|
|
487
|
+
that happens on release never runs. The press names the owner and the
|
|
488
|
+
release ends it.
|
|
489
|
+
"""
|
|
490
|
+
if event.action is MouseAction.DOWN:
|
|
491
|
+
self._mouse_capture = target
|
|
492
|
+
elif event.action is MouseAction.UP:
|
|
493
|
+
self._mouse_capture = None
|
|
494
|
+
|
|
495
|
+
def _track_selection(self, event: MouseEvent, path: list[Widget]) -> None:
|
|
496
|
+
"""Select the painted frame when a drag starts on text no widget owns.
|
|
497
|
+
|
|
498
|
+
Panels, the header, and the status line draw text without modelling it,
|
|
499
|
+
so the frame is the source: the drag marks cells, and the characters
|
|
500
|
+
they hold are copied on release. A widget that selects its own text —
|
|
501
|
+
the transcript — keeps the drag and this stays out of the way.
|
|
502
|
+
"""
|
|
503
|
+
if event.action is MouseAction.DOWN:
|
|
504
|
+
if any(widget.selects_text for widget in path):
|
|
505
|
+
self._select_anchor = None
|
|
506
|
+
self._select_head = None
|
|
507
|
+
else:
|
|
508
|
+
self._select_anchor = (event.x, event.y)
|
|
509
|
+
self._select_head = (event.x, event.y)
|
|
510
|
+
elif event.action is MouseAction.MOVE and self._select_anchor is not None:
|
|
511
|
+
self._select_head = self._clamp_to_screen(event.x, event.y)
|
|
512
|
+
# The mark has to follow the pointer; nothing else will ask for a
|
|
513
|
+
# frame while the button is held.
|
|
514
|
+
self.invalidate()
|
|
515
|
+
elif event.action is MouseAction.UP and self._select_anchor is not None:
|
|
516
|
+
selected = self.screen_selection_text()
|
|
517
|
+
if selected:
|
|
518
|
+
self.copy(selected)
|
|
519
|
+
# The mark stays, the way a terminal keeps its selection: it is
|
|
520
|
+
# the only sign of what was just copied, and Ctrl-C clears it.
|
|
521
|
+
self.invalidate()
|
|
522
|
+
|
|
523
|
+
def _clamp_to_screen(self, x: int, y: int) -> tuple[int, int]:
|
|
524
|
+
"""Return one cell of the frame, however far outside the pointer went."""
|
|
525
|
+
return min(max(0, x), self.width - 1), min(max(0, y), self.height - 1)
|
|
526
|
+
|
|
527
|
+
def screen_selection_text(self) -> str:
|
|
528
|
+
"""Return the frame text the current drag covers, or an empty string."""
|
|
529
|
+
frame = self._frame
|
|
530
|
+
if frame is None or self._select_anchor is None or self._select_head is None:
|
|
531
|
+
return ""
|
|
532
|
+
(first_x, first_y), (last_x, last_y) = sorted((self._select_anchor, self._select_head))
|
|
533
|
+
rows: list[str] = []
|
|
534
|
+
height = len(frame.cells)
|
|
535
|
+
if height == 0 or last_y >= height:
|
|
536
|
+
return ""
|
|
537
|
+
# The decoder clamps to the screen; these two keep a hand-built event
|
|
538
|
+
# from naming a row above the frame.
|
|
539
|
+
first_y = max(0, first_y)
|
|
540
|
+
if first_y > last_y:
|
|
541
|
+
return ""
|
|
542
|
+
for y in range(first_y, last_y + 1):
|
|
543
|
+
left = first_x if y == first_y else 0
|
|
544
|
+
# The head cell is the one under the pointer, and a selection names
|
|
545
|
+
# the text it was dragged over, so the row ends before it.
|
|
546
|
+
right = last_x if y == last_y else frame.width
|
|
547
|
+
rows.append(_frame_row(frame, y, left, right).rstrip())
|
|
548
|
+
while rows and not rows[0]:
|
|
549
|
+
rows.pop(0)
|
|
550
|
+
while rows and not rows[-1]:
|
|
551
|
+
rows.pop()
|
|
552
|
+
return "\n".join(rows)
|
|
553
|
+
|
|
554
|
+
def clear_screen_selection(self) -> None:
|
|
555
|
+
"""Drop the frame selection and repaint without its mark."""
|
|
556
|
+
if self._select_anchor is None and self._select_head is None:
|
|
557
|
+
return
|
|
558
|
+
self._select_anchor = None
|
|
559
|
+
self._select_head = None
|
|
560
|
+
self.invalidate()
|
|
561
|
+
|
|
562
|
+
def _run_binding(self, event: AnyEvent, priority: str) -> bool:
|
|
563
|
+
"""Run the command bound to this event at one priority, if any."""
|
|
564
|
+
command = self.keymap.resolve(event, priority=priority)
|
|
565
|
+
if command is None or not self.commands.run(command, event, self):
|
|
566
|
+
return False
|
|
567
|
+
self.invalidate()
|
|
568
|
+
return True
|
|
569
|
+
|
|
570
|
+
def _routable(self) -> list[Screen]:
|
|
571
|
+
"""Return screens that may receive input, topmost first.
|
|
572
|
+
|
|
573
|
+
Paint-only layers are skipped, so a toast drawn over the shell never
|
|
574
|
+
swallows a wheel notch or a keystroke meant for what is underneath.
|
|
575
|
+
"""
|
|
576
|
+
screens = [screen for screen in self.screens if screen.interactive]
|
|
577
|
+
for index in range(len(screens) - 1, -1, -1):
|
|
578
|
+
if screens[index].modal:
|
|
579
|
+
screens = screens[index:]
|
|
580
|
+
break
|
|
581
|
+
return list(reversed(screens))
|
|
582
|
+
|
|
583
|
+
def _target(self, event: AnyEvent) -> Widget | None:
|
|
584
|
+
"""Return the widget an event is addressed to before any bubbling."""
|
|
585
|
+
if isinstance(event, MouseEvent):
|
|
586
|
+
if event.action in (MouseAction.MOVE, MouseAction.UP):
|
|
587
|
+
captured = self._captured_widget()
|
|
588
|
+
if captured is not None:
|
|
589
|
+
return captured
|
|
590
|
+
return self._hit(event.x, event.y)
|
|
591
|
+
focused = self.focus_manager.focused
|
|
592
|
+
return focused if focused is not None else self.screens.top.widget
|
|
593
|
+
|
|
594
|
+
def _captured_widget(self) -> Widget | None:
|
|
595
|
+
"""Return the widget holding the mouse, dropping it once its screen is gone."""
|
|
596
|
+
widget = self._mouse_capture
|
|
597
|
+
if widget is None:
|
|
598
|
+
return None
|
|
599
|
+
if any(_holds(screen.widget, widget) for screen in self._routable()):
|
|
600
|
+
return widget
|
|
601
|
+
self._mouse_capture = None
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
def _hit(self, x: int, y: int) -> Widget:
|
|
605
|
+
"""Return the topmost widget under one cell, skipping covered screens."""
|
|
606
|
+
routable = self._routable()
|
|
607
|
+
for screen in routable:
|
|
608
|
+
hit = _hit_test(screen.widget, x, y)
|
|
609
|
+
if hit is not None:
|
|
610
|
+
return hit
|
|
611
|
+
return routable[0].widget
|
|
612
|
+
|
|
613
|
+
@staticmethod
|
|
614
|
+
def _path(widget: Widget) -> list[Widget]:
|
|
615
|
+
"""Return the root-to-widget chain used for capture and bubble phases."""
|
|
616
|
+
path: list[Widget] = []
|
|
617
|
+
node: Widget | None = widget
|
|
618
|
+
while node is not None:
|
|
619
|
+
path.append(node)
|
|
620
|
+
node = node.parent
|
|
621
|
+
path.reverse()
|
|
622
|
+
return path
|
|
623
|
+
|
|
624
|
+
def _ensure_mounted(self) -> None:
|
|
625
|
+
"""Mount the tree on first use so callers need not mount it themselves."""
|
|
626
|
+
if not self._mounted:
|
|
627
|
+
self.mount()
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _invert(style: Style) -> Style:
|
|
631
|
+
"""Flip reverse video, which is how a selection marks a cell."""
|
|
632
|
+
return replace(style, reverse=not style.reverse)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _frame_row(frame: Canvas, y: int, left: int, right: int) -> str:
|
|
636
|
+
"""Return the characters one frame row holds between two columns."""
|
|
637
|
+
return "".join(cell.character for cell in frame.cells[y][max(0, left) : right])
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _holds(root: Widget, widget: Widget) -> bool:
|
|
641
|
+
"""Return whether ``widget`` is ``root`` or one of its descendants."""
|
|
642
|
+
node: Widget | None = widget
|
|
643
|
+
while node is not None:
|
|
644
|
+
if node is root:
|
|
645
|
+
return True
|
|
646
|
+
node = node.parent
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _hit_test(widget: Widget, x: int, y: int) -> Widget | None:
|
|
651
|
+
"""Return the deepest widget containing one cell, searching paint order."""
|
|
652
|
+
if not widget.rect.contains(x, y):
|
|
653
|
+
return None
|
|
654
|
+
for child in reversed(widget.children):
|
|
655
|
+
hit = _hit_test(child, x, y)
|
|
656
|
+
if hit is not None:
|
|
657
|
+
return hit
|
|
658
|
+
return widget
|