zettcode-app 0.4.2__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 (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,442 @@
1
+ """The page ``/plugins`` opens: the plugins the program carries, the ones installed beside it, and a switch for each.
2
+
3
+ Which rows have a switch is not this page's decision: the engine's catalog works it out (a compiled-in plugin has one
4
+ only when it declares the configuration key that decides it; an installed one always has, unless a line of the
5
+ configuration holds it), so the editor's panel and this one cannot disagree. What a switch writes is a promise about
6
+ the *next* start — the file is read once, when a process starts — and this one keeps what it already loaded.
7
+
8
+ Nothing here imports the engine: a row is the few facts the page draws, and the command that opens it fills them in.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+ from dataclasses import dataclass
15
+ from time import monotonic
16
+
17
+ from zettcode.plugin_api.panels import pressed
18
+ from zettcode.plugin_api.switch import SWITCH_WIDTH, Rail, draw_switch
19
+ from zettcode.tui import ARROW_DOWN, ARROW_UP, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
20
+ from zettcode.tui.render import Style, blend, display_width, truncate
21
+
22
+ #: Rows the page spends on something other than the list: a blank, the title, a blank under it, a blank above the
23
+ #: description, the description, and the keys.
24
+ CHROME = 6
25
+
26
+ #: The first row of the list, counted from the top of the page.
27
+ LIST_TOP = 3
28
+
29
+ #: The headings the rows are grouped under: what the program carries, and what was installed beside it.
30
+ BUILTIN = "Built in"
31
+ INSTALLED = "Third-party"
32
+
33
+ #: What a row without a switch says in the switch column, when the configuration file holds the switch.
34
+ OFF_IN_CONFIG = "fixed"
35
+
36
+ #: Columns the plugin's name takes, the switch, and the word beside it. The name column fits the longest one the
37
+ #: program ships (``project-instructions``) with room to breathe before the switch.
38
+ NAME_WIDTH = 24
39
+ WORD_WIDTH = 8
40
+
41
+ #: The least columns a description is worth drawing in; narrower and the line under the list says it alone.
42
+ MIN_DESCRIPTION = 16
43
+
44
+ #: Columns kept free at the right of the list, where the marks saying it scrolls go.
45
+ EDGE = 2
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class PluginRow:
50
+ """One plugin as the page draws it.
51
+
52
+ Attributes:
53
+ name: What the plugin calls itself.
54
+ origin: The distribution that carries it, and the name a switch is written with.
55
+ builtin: Compiled into the program, so it is switched by a configuration key rather than `[plugins] disable`.
56
+ enabled: Whether the next start does its work.
57
+ failure: Why it could not load here, or ``""`` when it did.
58
+ setting: The configuration key that decides a compiled-in plugin, such as ``"skills.enabled"``.
59
+ switchable: Whether there is a switch to draw; the catalog decides it, so the editor's panel agrees.
60
+ why: Why there is none, when there is none.
61
+ note: What else the row has to say — a plugin that lost its name to one loaded before it, for instance.
62
+ """
63
+
64
+ name: str
65
+ origin: str
66
+ builtin: bool = False
67
+ enabled: bool = True
68
+ failure: str = ""
69
+ setting: str = ""
70
+ switchable: bool = True
71
+ why: str = ""
72
+ note: str = ""
73
+
74
+
75
+ class PluginsPage(Widget):
76
+ """Choose which plugins the next start does the work of.
77
+
78
+ Shape::
79
+
80
+ Plugins 3 of 5 on
81
+
82
+ Built in ────────────────────────────────────────────────────────────
83
+ ▸ skills ━━━━● on skills.enabled
84
+ mcp ━━━━● on mcp.enabled
85
+ todos ●──── off harness.todowrite
86
+ coding failed could not load: no plugin provides the model
87
+ Third-party ─────────────────────────────────────────────────────────
88
+ monitor ━━━━● on zettcode-monitor
89
+ acme ●──── off zettcode-acme
90
+ held fixed zettcode-held
91
+
92
+ skills (skills.enabled) loads at the next start
93
+ ↑ ↓ plugin · ← → switch · enter apply · esc cancel
94
+
95
+ Up and Down move the marker, round from the last row to the first and back; Left, Right and Space move the switch
96
+ of the marked plugin; Enter hands every change to the caller, which writes them down and closes the page; Escape
97
+ writes nothing.
98
+
99
+ Every row has a switch but the ones a reader cannot move, and those say why on the line under the list: a
100
+ compiled-in plugin that could not load (its setting is unknown to this process, so the row has none), one whose
101
+ switch is a line in the configuration file, and a group the configuration turned off as a whole. A compiled-in
102
+ plugin with no setting of its own is not on the page at all — there would be nothing to press. It is an installed
103
+ plugin that failed which keeps its switch, because `[plugins] disable` is exactly what would leave it out.
104
+
105
+ Args:
106
+ rows: The plugins a list shows, in the order they are grouped: the program's first.
107
+ on_apply: Receives every changed plugin's origin and the state to write when Enter is pressed.
108
+ on_cancel: Called when the reader backs out.
109
+ """
110
+
111
+ HINT = f"{ARROW_UP} {ARROW_DOWN} plugin {SEPARATOR} \u2190 \u2192 switch {SEPARATOR} enter apply {SEPARATOR} esc cancel"
112
+
113
+ def __init__(
114
+ self,
115
+ rows: Sequence[PluginRow],
116
+ *,
117
+ on_apply: Callable[[dict[str, bool]], None],
118
+ on_cancel: Callable[[], None],
119
+ ) -> None:
120
+ """Start with every switch where the file has it, and the marker on the first plugin."""
121
+ super().__init__()
122
+ self.rows: tuple[PluginRow, ...] = tuple(rows)
123
+ self.start = {row.origin: row.enabled for row in self.rows if row.switchable}
124
+ self.switches = {row.origin: Rail(("off", "on"), int(row.enabled)) for row in self.rows if row.switchable}
125
+ self.on_apply = on_apply
126
+ self.on_cancel = on_cancel
127
+ #: The plugin the marker is on, as an index into :attr:`rows`.
128
+ self.mark = 0
129
+ #: The first visible row of the list, counted in list rows (headings included).
130
+ self.top = 0
131
+ #: Why the last press did nothing, on the line under the list; cleared by the next move.
132
+ self.note: str | None = None
133
+ self._token = f"plugins-panel:{id(self)}"
134
+ self._lines: list[tuple[str, int]] = []
135
+ previous = None
136
+ for index, row in enumerate(self.rows):
137
+ title = BUILTIN if row.builtin else INSTALLED
138
+ if title != previous:
139
+ self._lines.append(("heading", index))
140
+ previous = title
141
+ self._lines.append(("plugin", index))
142
+
143
+ @property
144
+ def focusable(self) -> bool:
145
+ """Take focus when the shell pushes the page, so the keys reach it."""
146
+ return True
147
+
148
+ def switchable(self, row: PluginRow) -> bool:
149
+ """Return whether this plugin has a switch here: the catalog works it out, so every front end agrees."""
150
+ return row.switchable
151
+
152
+ @property
153
+ def shown(self) -> dict[str, bool]:
154
+ """Return every switchable plugin's origin and the state its switch is heading for."""
155
+ return {name: bool(rail.selected) for name, rail in self.switches.items()}
156
+
157
+ @property
158
+ def changes(self) -> dict[str, bool]:
159
+ """Return the switches that are anywhere but where `[plugins] disable` has them."""
160
+ return {name: state for name, state in self.shown.items() if state != self.start[name]}
161
+
162
+ @property
163
+ def moving(self) -> bool:
164
+ """Return whether a switch is still on its way."""
165
+ return any(rail.moving for rail in self.switches.values())
166
+
167
+ def _now(self) -> float:
168
+ return self.app.clock() if self.app is not None else monotonic()
169
+
170
+ def set(self, origin: str, on: bool) -> None:
171
+ """Send one plugin's switch to on or off, and say on the line below why when it cannot move."""
172
+ marked = self.rows[self.mark] if self.rows else None
173
+ row = (
174
+ marked
175
+ if marked is not None and marked.origin == origin
176
+ else next((row for row in self.rows if row.origin == origin), None)
177
+ )
178
+ if row is None:
179
+ return
180
+ if not self.switchable(row):
181
+ self.mark = self.rows.index(row)
182
+ self.note = self._why_fixed(row)
183
+ return
184
+ self.note = None
185
+ reduced = self.app is not None and self.app.reduced_motion
186
+ if self.switches[origin].choose(int(on), self._now(), instant=reduced) and self.app is not None:
187
+ self.app.scheduler.animate(self._token)
188
+
189
+ def on_unmount(self) -> None:
190
+ """Release the frame loop, so a page put away mid-slide stops costing frames."""
191
+ for rail in self.switches.values():
192
+ rail.moving = False
193
+ if self.app is not None:
194
+ self.app.scheduler.animate(self._token, active=False)
195
+
196
+ def on_tick(self) -> bool:
197
+ """Hand the frame loop back once every switch has arrived; say whether the picture is still changing."""
198
+ now = self._now()
199
+ landed = False
200
+ for rail in self.switches.values():
201
+ landed |= rail.arrive(now)
202
+ if not self.moving and self.app is not None:
203
+ self.app.scheduler.animate(self._token, active=False)
204
+ return self.moving or landed
205
+
206
+ def capture_event(self, event, host: Host) -> bool:
207
+ """Take Escape before the focused widget can."""
208
+ if pressed(event) == "escape":
209
+ self.on_cancel()
210
+ return True
211
+ return False
212
+
213
+ def handle(self, event, host: Host) -> bool:
214
+ """Move with Up and Down, flip with Left, Right and Space, and apply with Enter."""
215
+ if isinstance(event, MouseEvent):
216
+ return self._handle_mouse(event)
217
+ if not self.rows:
218
+ return False
219
+ name = self.rows[self.mark].origin
220
+ match pressed(event):
221
+ case "up" | "backtab":
222
+ self._move(self.mark - 1)
223
+ case "down" | "tab":
224
+ self._move(self.mark + 1)
225
+ case "pageup":
226
+ self._move(self.mark - max(1, self._view() - 1))
227
+ case "pagedown":
228
+ self._move(self.mark + max(1, self._view() - 1))
229
+ case "home":
230
+ self._move(0)
231
+ case "end":
232
+ self._move(len(self.rows) - 1)
233
+ case "left":
234
+ self.set(name, False)
235
+ case "right":
236
+ self.set(name, True)
237
+ case " ":
238
+ rail = self.switches.get(name)
239
+ self.set(name, not (rail.selected if rail is not None else False))
240
+ case "enter":
241
+ self.on_apply(self.changes)
242
+ case _:
243
+ return False
244
+ return True
245
+
246
+ def _move(self, index: int) -> None:
247
+ """Put the marker on a row, round the ends, and take back what the last press said."""
248
+ self.note = None
249
+ self.mark = index % len(self.rows) if self.rows else 0
250
+
251
+ def _handle_mouse(self, event: MouseEvent) -> bool:
252
+ """Mark the row under a click, and flip its switch when the click is on the switch or its word."""
253
+ if event.action is MouseAction.SCROLL_UP:
254
+ self._move(self.mark - 1)
255
+ return True
256
+ if event.action is MouseAction.SCROLL_DOWN:
257
+ self._move(self.mark + 1)
258
+ return True
259
+ if event.action is not MouseAction.DOWN:
260
+ return False
261
+ offset = event.y - (self.rect.y + LIST_TOP)
262
+ index = self.top + offset
263
+ if not 0 <= offset < self._view() or index >= len(self._lines):
264
+ return False
265
+ kind, at = self._lines[index]
266
+ if kind != "plugin":
267
+ return False
268
+ row = self.rows[at]
269
+ start = self._switch_column()
270
+ if start <= event.x < start + SWITCH_WIDTH + 1 + WORD_WIDTH:
271
+ self.mark = at
272
+ rail = self.switches.get(row.origin)
273
+ self.set(row.origin, not (rail.selected if rail is not None else False))
274
+ else:
275
+ self._move(at)
276
+ return True
277
+
278
+ def _view(self) -> int:
279
+ """Return how many rows of the list the page has room for."""
280
+ return max(1, self.rect.height - CHROME)
281
+
282
+ def _switch_column(self) -> int:
283
+ """Return the column the first switch cell is in."""
284
+ return self.rect.x + 4 + NAME_WIDTH
285
+
286
+ def _reveal(self) -> None:
287
+ """Scroll the list the least that shows the marked plugin, and its heading when it is the first of its group."""
288
+ if not self.rows:
289
+ return
290
+ view = self._view()
291
+ row = self._lines.index(("plugin", self.mark))
292
+ first = row - 1 if self._lines[row - 1][0] == "heading" else row
293
+ if first < self.top:
294
+ self.top = first
295
+ if row >= self.top + view:
296
+ self.top = row - view + 1
297
+ self.top = max(0, min(self.top, max(0, len(self._lines) - view)))
298
+
299
+ def render(self, canvas: Canvas) -> None:
300
+ """Fill the page, then draw the title, the list, what the marked plugin does, and the keys."""
301
+ if self.rect.empty:
302
+ return
303
+ theme = self.theme
304
+ surface = theme.surface_alt
305
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
306
+ x = self.rect.x + 2
307
+ room = max(0, self.rect.width - 4)
308
+ muted = Style(foreground=theme.muted, background=surface)
309
+ bold = Style(foreground=theme.text, background=surface, bold=True)
310
+ canvas.draw_text(x, self.rect.y + 1, "Plugins", bold, max_width=room)
311
+ on = sum(1 for state in self.shown.values() if state)
312
+ total = len(self.shown)
313
+ changes = len(self.changes)
314
+ suffix = " (not applied)" if changes else ""
315
+ gap = room - display_width("Plugins") - 2
316
+ candidates = (
317
+ [
318
+ f"{on} of {total} on{suffix}",
319
+ f"{on} of {total} on",
320
+ f"{on}/{total}",
321
+ ]
322
+ if total
323
+ else ["nothing here can be switched", "no switch"]
324
+ )
325
+ state = next((text for text in candidates if display_width(text) <= gap), "")
326
+ if state:
327
+ canvas.draw_text(
328
+ self.rect.right - 2 - display_width(state),
329
+ self.rect.y + 1,
330
+ state,
331
+ Style(foreground=theme.accent if changes else theme.muted, background=surface, bold=bool(changes)),
332
+ )
333
+ self._reveal()
334
+ self._draw_list(canvas)
335
+ if not self.rows:
336
+ canvas.draw_text(x, self.rect.y + LIST_TOP, "No plugin is loaded.", muted, max_width=room)
337
+ if self.rows:
338
+ row = self.rows[self.mark]
339
+ head = f"{row.name}:"
340
+ said = f"{head} {self.note or self._description(row)}"
341
+ canvas.draw_text(x, self.rect.bottom - 2, truncate(said, room), muted, max_width=room)
342
+ canvas.draw_text(x, self.rect.bottom - 2, head, bold, max_width=min(room, display_width(head)))
343
+ canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
344
+
345
+ def _description(self, row: PluginRow) -> str:
346
+ """Return what the marked plugin does at the next start, or why it is not the reader's to switch."""
347
+ if row.note:
348
+ return row.note
349
+ if row.failure:
350
+ return f"could not load: {row.failure}"
351
+ if not row.switchable:
352
+ return row.why
353
+ rail = self.switches[row.origin]
354
+ state = "loads at the next start" if rail.selected else "is left out of the next start"
355
+ where = f" ({row.setting})" if row.setting else ""
356
+ return f"{row.origin}{where} {state}"
357
+
358
+ def _why_fixed(self, row: PluginRow) -> str:
359
+ """Return why the switch of one row cannot move, for the line under the list."""
360
+ if row.note:
361
+ return row.note
362
+ return "it could not load, so there is no switch to move" if row.failure else row.why
363
+
364
+ def _draw_list(self, canvas: Canvas) -> None:
365
+ """Draw the visible rows: a heading for each group, then its plugins with their switches."""
366
+ theme = self.theme
367
+ surface = theme.surface_alt
368
+ x = self.rect.x + 2
369
+ right = self.rect.right - 2
370
+ view = self._view()
371
+ groove = blend(theme.muted, surface, 0.55)
372
+ now = self._now()
373
+ for offset in range(view):
374
+ index = self.top + offset
375
+ if index >= len(self._lines):
376
+ break
377
+ y = self.rect.y + LIST_TOP + offset
378
+ kind, at = self._lines[index]
379
+ row = self.rows[at]
380
+ if kind == "heading":
381
+ title = BUILTIN if row.builtin else INSTALLED
382
+ canvas.draw_text(x, y, title, Style(foreground=theme.muted, background=surface, bold=True))
383
+ start = x + display_width(title) + 1
384
+ if start < right:
385
+ canvas.draw_text(start, y, "\u2500" * (right - start), Style(foreground=groove, background=surface))
386
+ continue
387
+ rail = self.switches.get(row.origin) if row.switchable else None
388
+ on = rail.position(now) if rail is not None else float(row.enabled)
389
+ marked = at == self.mark
390
+ if marked:
391
+ canvas.draw_text(x, y, "\u25b8", Style(foreground=theme.accent, background=surface, bold=True))
392
+ lit = theme.text if marked or on >= 0.5 else blend(theme.muted, surface, 0.25)
393
+ canvas.draw_text(
394
+ x + 2,
395
+ y,
396
+ truncate(row.name, NAME_WIDTH),
397
+ Style(foreground=lit, background=surface, bold=marked),
398
+ max_width=NAME_WIDTH,
399
+ )
400
+ switch = self._switch_column()
401
+ word_at = switch + SWITCH_WIDTH + 1
402
+ if rail is None:
403
+ if row.note:
404
+ text, colour = "name taken", theme.muted
405
+ elif row.failure:
406
+ text, colour = "failed", theme.error
407
+ else:
408
+ text, colour = OFF_IN_CONFIG, theme.warning
409
+ canvas.draw_text(
410
+ switch,
411
+ y,
412
+ truncate(text, SWITCH_WIDTH + 1 + WORD_WIDTH),
413
+ Style(foreground=colour, background=surface),
414
+ )
415
+ else:
416
+ draw_switch(canvas, theme, y, switch, on, groove)
417
+ canvas.draw_text(
418
+ word_at,
419
+ y,
420
+ "on" if rail.selected else "off",
421
+ Style(
422
+ foreground=theme.accent if rail.selected else blend(theme.muted, surface, 0.25),
423
+ background=surface,
424
+ ),
425
+ )
426
+ start = word_at + WORD_WIDTH + 1
427
+ room = right - EDGE - start
428
+ if room >= MIN_DESCRIPTION:
429
+ if row.failure:
430
+ text, colour = f"could not load: {row.failure}", theme.error
431
+ elif row.builtin:
432
+ text, colour = row.setting, blend(theme.muted, surface, 0.35)
433
+ else:
434
+ text, colour = row.origin, blend(theme.muted, surface, 0.35)
435
+ canvas.draw_text(
436
+ start, y, truncate(text, room), Style(foreground=colour, background=surface), max_width=room
437
+ )
438
+ edge = Style(foreground=theme.muted, background=surface)
439
+ if self.top > 0:
440
+ canvas.draw_text(self.rect.right - 1, self.rect.y + LIST_TOP, "\u25b2", edge)
441
+ if self.top + view < len(self._lines):
442
+ canvas.draw_text(self.rect.right - 1, self.rect.y + LIST_TOP + view - 1, "\u25bc", edge)
@@ -0,0 +1,68 @@
1
+ """The shell's root widget, which drives the activity animation tick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from zettcode.tui import VBox, Widget
8
+
9
+ if TYPE_CHECKING: # pragma: no cover - the shell imports this module at runtime
10
+ from ..app import ZettCodeApp
11
+ from .pet import PetView
12
+
13
+
14
+ class ZettCodeRoot(Widget):
15
+ """Thin root that advances the activity frame while a request runs.
16
+
17
+ It holds the controller only to read ``busy`` and advance the transcript's
18
+ frame on every tick; the type is imported under ``TYPE_CHECKING`` so the
19
+ shell can import this module without a cycle. The pet stands on top of the
20
+ body, so it is painted last and is the first thing a click on its cells finds.
21
+ """
22
+
23
+ def __init__(self, controller: ZettCodeApp, body: VBox, pet: PetView | None = None) -> None:
24
+ """Keep the controller reachable from the tick hook."""
25
+ super().__init__()
26
+ self.controller = controller
27
+ self.body = body
28
+ self.pet = pet
29
+
30
+ @property
31
+ def children(self) -> tuple[Widget, ...]:
32
+ """Expose the body widget the root lays out, and the pet over it."""
33
+ return (self.body,) if self.pet is None else (self.body, self.pet)
34
+
35
+ def layout(self, rect) -> None:
36
+ """Give the body the full application rectangle, then let the pet stand in the conversation it left."""
37
+ super().layout(rect)
38
+ self.body.layout(rect)
39
+ if self.pet is not None:
40
+ self.pet.layout(rect)
41
+
42
+ def render(self, canvas) -> None:
43
+ """Paint the body into the shared canvas, and the pet over it."""
44
+ self.body.render(canvas)
45
+ if self.pet is not None:
46
+ self.pet.render(canvas)
47
+
48
+ def cursor(self):
49
+ """Forward the cursor request to the body."""
50
+ return self.body.cursor()
51
+
52
+ def on_tick(self) -> bool:
53
+ """Advance the activity frame while a request is running.
54
+
55
+ A flash in the row above the composer asks for the next frame too: it
56
+ expires on the clock rather than on an event, so something has to keep
57
+ painting until it does. The flash and the effort gauge ask by invalidating,
58
+ which the loop sees for itself; what this reports is the animation.
59
+
60
+ Returns:
61
+ Whether the activity animation's picture is different from the last one
62
+ drawn. ``False`` for most of the steps of a long wait: they repeat.
63
+ """
64
+ self.controller.settle_flash()
65
+ self.controller.settle_effort_animation()
66
+ if self.controller.busy and (self.app is None or not self.app.reduced_motion):
67
+ return self.controller.transcript.advance_frame()
68
+ return False