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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
@@ -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