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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- 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
|