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,653 @@
1
+ """The bottom panel ``/config`` opens for one model: every setting on a row, the name on the left and its value on the right."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+ from time import monotonic
8
+ from typing import Literal
9
+
10
+ from zettcode.plugin_api.panels import pressed
11
+ from zettcode.plugin_api.switch import SWITCH_WIDTH, Rail, draw_switch
12
+ from zettcode.tui import (
13
+ ARROW_DOWN,
14
+ ARROW_UP,
15
+ PROMPT,
16
+ SEPARATOR,
17
+ AnyEvent,
18
+ Canvas,
19
+ Host,
20
+ MouseAction,
21
+ MouseEvent,
22
+ Rect,
23
+ Style,
24
+ Widget,
25
+ event_key,
26
+ )
27
+ from zettcode.tui.core.geometry import Point
28
+ from zettcode.tui.render import blend, display_width, truncate
29
+
30
+ from .form_page import LineArea, SecretLine
31
+
32
+ #: What an answer is: the trimmed text of a typed row, whether a switch is on, the word a choice is on.
33
+ Answer = str | bool
34
+
35
+ #: The columns kept free at each side, the width of the marker before a row's label, and of the label itself.
36
+ MARGIN = 2
37
+ MARKER_WIDTH = 2
38
+ LABEL_WIDTH = 16
39
+
40
+ #: Rows besides the settings: the title, the reason a save was refused, a blank above the line that explains the row
41
+ #: the cursor is in, that line, and the keys.
42
+ CHROME_ROWS = 5
43
+
44
+ #: The gap between a switch's rail and its word, and between the words of a choice.
45
+ WORD_GAP = 1
46
+ CHOICE_GAP = 3
47
+
48
+ #: The marks of a choice: the word it is on, and the others.
49
+ ON, OFF = "\u25c9", "\u25cb"
50
+
51
+ Kind = Literal["text", "secret", "switch", "choice"]
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Setting:
56
+ """One row of the panel: a value of the model that can be changed.
57
+
58
+ Attributes:
59
+ key: What the row is called in the answers handed to the page's owner.
60
+ label: What the reader sees on the left.
61
+ kind: ``text`` and ``secret`` are typed; ``switch`` is a rail with a knob between two words; ``choice`` is
62
+ one word out of several.
63
+ initial: The text a typed row starts with.
64
+ placeholder: Shown, muted, while a typed row is empty.
65
+ hint: One sentence shown under the rows while the cursor is in this one.
66
+ required: Refuse a save while a typed row is empty; the label then carries a star.
67
+ words: For a switch, the word of off and then of on; for a choice, its words.
68
+ value: For a switch or a choice, the index of the word it starts on.
69
+ """
70
+
71
+ key: str
72
+ label: str
73
+ kind: Kind = "text"
74
+ initial: str = ""
75
+ placeholder: str = ""
76
+ hint: str = ""
77
+ required: bool = False
78
+ words: tuple[str, ...] = ()
79
+ value: int = 0
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Action:
84
+ """One thing to do with the model, drawn as a row of its own under the settings.
85
+
86
+ Attributes:
87
+ key: What the page's owner is told was chosen.
88
+ label: The row's name.
89
+ note: What it does, in a few words; also the line under the rows while the cursor is on it.
90
+ saves: Whether the settings are taken with it, and so refused when a required one is empty.
91
+ """
92
+
93
+ key: str
94
+ label: str
95
+ note: str
96
+ saves: bool = False
97
+
98
+
99
+ class _Row(Widget):
100
+ """What every row of the panel shares: the marker and the label on the left."""
101
+
102
+ def __init__(self, page: ModelSettingsPage, label: str, *, hint: str = "", required: bool = False) -> None:
103
+ """Hold the page the row is on, the words it is called, and the sentence that explains it."""
104
+ super().__init__()
105
+ self.page = page
106
+ self.label = f"{label} *" if required else label
107
+ self.hint = hint
108
+
109
+ @property
110
+ def here(self) -> bool:
111
+ """Return whether the cursor is in this row."""
112
+ return self.target.focused
113
+
114
+ @property
115
+ def target(self) -> Widget:
116
+ """Return the widget that takes focus when the cursor moves to this row."""
117
+ return self
118
+
119
+ @property
120
+ def column(self) -> int:
121
+ """Return the column the row's value starts at."""
122
+ return self.rect.x + MARGIN + MARKER_WIDTH + LABEL_WIDTH
123
+
124
+ def draw_label(self, canvas: Canvas) -> None:
125
+ """Draw the marker when the cursor is here, then the label."""
126
+ theme = self.theme
127
+ surface = theme.surface_alt
128
+ x, y = self.rect.x + MARGIN, self.rect.y
129
+ if self.here:
130
+ canvas.draw_text(x, y, PROMPT, Style(foreground=theme.accent, background=surface, bold=True))
131
+ canvas.draw_text(
132
+ x + MARKER_WIDTH,
133
+ y,
134
+ truncate(self.label, LABEL_WIDTH - 1),
135
+ Style(foreground=theme.text, background=surface, bold=self.here),
136
+ max_width=LABEL_WIDTH - 1,
137
+ )
138
+
139
+
140
+ class _TextRow(_Row):
141
+ """A typed row: the editor sits on a band to the right of the label, so it reads as somewhere to type."""
142
+
143
+ def __init__(self, page: ModelSettingsPage, setting: Setting) -> None:
144
+ """Hold one editor, a bullet-painting one for a secret."""
145
+ super().__init__(page, setting.label, hint=setting.hint, required=setting.required)
146
+ self.setting = setting
147
+ self.area: LineArea = (SecretLine if setting.kind == "secret" else LineArea)(
148
+ prompt="", placeholder=setting.placeholder, max_height=1, on_change=page.edited
149
+ )
150
+ if setting.initial:
151
+ self.area.set_text(setting.initial)
152
+
153
+ @property
154
+ def children(self) -> tuple[Widget, ...]:
155
+ """Expose the editor."""
156
+ return (self.area,)
157
+
158
+ @property
159
+ def target(self) -> Widget:
160
+ """Return the editor: the cursor lives in it."""
161
+ return self.area
162
+
163
+ @property
164
+ def answer(self) -> Answer:
165
+ """Return what is typed, trimmed."""
166
+ return self.area.text.strip()
167
+
168
+ def layout(self, rect: Rect) -> None:
169
+ """Put the editor after the label, as wide as the row has left."""
170
+ super().layout(rect)
171
+ if rect.empty:
172
+ self.area.layout(Rect(rect.x, rect.y, 0, 0))
173
+ return
174
+ start = self.column
175
+ self.area.layout(Rect(start, rect.y, max(0, rect.right - MARGIN - start), 1))
176
+
177
+ def render(self, canvas: Canvas) -> None:
178
+ """Draw the label, the band, and the editor."""
179
+ if self.rect.empty:
180
+ return
181
+ self.draw_label(canvas)
182
+ theme = self.theme
183
+ band = Style(background=theme.surface)
184
+ canvas.fill(self.area.rect.x - 1, self.area.rect.y, self.area.rect.width + 2, 1, band)
185
+ self.area.render(canvas)
186
+
187
+ def handle(self, event: AnyEvent, host: Host) -> bool:
188
+ """Take a press on the label or the band's edge: the editor takes one inside it for itself."""
189
+ if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
190
+ host.focus(self.area)
191
+ return True
192
+ return False
193
+
194
+ def cursor(self) -> Point | None:
195
+ """Forward the editor's cursor."""
196
+ return self.area.cursor()
197
+
198
+
199
+ class _RailRow(_Row):
200
+ """A switch or a choice: Left and Right move it, Space turns a switch over."""
201
+
202
+ def __init__(self, page: ModelSettingsPage, setting: Setting) -> None:
203
+ """Start the knob on the setting's word."""
204
+ super().__init__(page, setting.label, hint=setting.hint)
205
+ self.setting = setting
206
+ self.rail = Rail(setting.words, setting.value)
207
+ self.switch = setting.kind == "switch"
208
+ self._token = f"model-settings:{setting.key}:{id(self)}"
209
+ #: Where each word of a choice was drawn at the last paint, for a click.
210
+ self._spans: list[tuple[int, int]] = []
211
+
212
+ @property
213
+ def focusable(self) -> bool:
214
+ """Take the cursor: there is no editor in the row to hold it."""
215
+ return True
216
+
217
+ @property
218
+ def answer(self) -> Answer:
219
+ """Return whether a switch is on, or the word a choice is on, once the knob arrives."""
220
+ return bool(self.rail.selected) if self.switch else self.rail.words[self.rail.selected]
221
+
222
+ def _now(self) -> float:
223
+ return self.app.clock() if self.app is not None else monotonic()
224
+
225
+ def choose(self, index: int) -> None:
226
+ """Send the knob to a word; a switch slides there, a choice jumps."""
227
+ instant = not self.switch or (self.app is not None and self.app.reduced_motion)
228
+ if self.rail.choose(index, self._now(), instant=instant) and self.app is not None:
229
+ self.app.scheduler.animate(self._token)
230
+ self.page.edited()
231
+ self.invalidate()
232
+
233
+ def on_unmount(self) -> None:
234
+ """Release the frame loop, so a panel put away mid-slide stops costing frames."""
235
+ self.rail.moving = False
236
+ if self.app is not None:
237
+ self.app.scheduler.animate(self._token, active=False)
238
+
239
+ def on_tick(self) -> bool:
240
+ """Hand the frame loop back once the knob has arrived; say whether the picture is still changing."""
241
+ landed = self.rail.arrive(self._now())
242
+ if not self.rail.moving and self.app is not None:
243
+ self.app.scheduler.animate(self._token, active=False)
244
+ return self.rail.moving or landed
245
+
246
+ def handle(self, event: AnyEvent, host: Host) -> bool:
247
+ """Move with Left and Right (Home and End go to the ends), and turn over or step on with Space."""
248
+ if isinstance(event, MouseEvent):
249
+ return self._press(event, host)
250
+ last = len(self.rail.words) - 1
251
+ match pressed(event):
252
+ case "left":
253
+ self.choose(self.rail.selected - 1)
254
+ case "right":
255
+ self.choose(self.rail.selected + 1)
256
+ case "home":
257
+ self.choose(0)
258
+ case "end":
259
+ self.choose(last)
260
+ case " ":
261
+ self.choose((self.rail.selected + 1) % (last + 1))
262
+ case _:
263
+ return False
264
+ return True
265
+
266
+ def _press(self, event: MouseEvent, host: Host) -> bool:
267
+ """Put the cursor here, and set the switch or the word under the click."""
268
+ if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
269
+ return False
270
+ host.focus(self)
271
+ if self.switch:
272
+ if (
273
+ self.column
274
+ <= event.x
275
+ < self.column + SWITCH_WIDTH + WORD_GAP + max(map(display_width, self.rail.words))
276
+ ):
277
+ self.choose(1 - self.rail.selected)
278
+ else:
279
+ for index, (start, stop) in enumerate(self._spans):
280
+ if start <= event.x < stop:
281
+ self.choose(index)
282
+ return True
283
+
284
+ def render(self, canvas: Canvas) -> None:
285
+ """Draw the label, then the rail and its word, or the words with the chosen one marked."""
286
+ if self.rect.empty:
287
+ return
288
+ self.draw_label(canvas)
289
+ theme = self.theme
290
+ surface = theme.surface_alt
291
+ y = self.rect.y
292
+ if self.switch:
293
+ position = self.rail.position(self._now())
294
+ on = bool(self.rail.selected)
295
+ draw_switch(canvas, theme, y, self.column, position, blend(theme.muted, surface, 0.55))
296
+ canvas.draw_text(
297
+ self.column + SWITCH_WIDTH + WORD_GAP,
298
+ y,
299
+ self.rail.words[self.rail.selected],
300
+ Style(foreground=theme.accent if on else blend(theme.muted, surface, 0.25), background=surface),
301
+ max_width=max(0, self.rect.right - MARGIN - self.column - SWITCH_WIDTH - WORD_GAP),
302
+ )
303
+ return
304
+ x = self.column
305
+ self._spans = []
306
+ for index, word in enumerate(self.rail.words):
307
+ chosen = index == self.rail.selected
308
+ width = display_width(word) + 2
309
+ self._spans.append((x, x + width))
310
+ style = (
311
+ Style(foreground=theme.accent, background=surface, bold=True)
312
+ if chosen
313
+ else Style(foreground=theme.muted, background=surface)
314
+ )
315
+ canvas.draw_text(x, y, f"{ON if chosen else OFF} {word}", style, max_width=max(0, self.rect.right - x))
316
+ x += width + CHOICE_GAP
317
+
318
+
319
+ class _ActionRow(_Row):
320
+ """A thing to do rather than a value: the label, and what it does beside it."""
321
+
322
+ def __init__(self, page: ModelSettingsPage, action: Action) -> None:
323
+ """Hold the action and the words it is drawn with."""
324
+ super().__init__(page, action.label, hint=action.note)
325
+ self.action = action
326
+
327
+ @property
328
+ def focusable(self) -> bool:
329
+ """Take the cursor, so Enter does it."""
330
+ return True
331
+
332
+ def render(self, canvas: Canvas) -> None:
333
+ """Draw the label and, muted after it, what it does."""
334
+ if self.rect.empty:
335
+ return
336
+ self.draw_label(canvas)
337
+ theme = self.theme
338
+ room = max(0, self.rect.right - MARGIN - self.column)
339
+ canvas.draw_text(
340
+ self.column,
341
+ self.rect.y,
342
+ truncate(self.action.note, room),
343
+ Style(foreground=blend(theme.muted, theme.surface_alt, 0.15), background=theme.surface_alt),
344
+ max_width=room,
345
+ )
346
+
347
+ def handle(self, event: AnyEvent, host: Host) -> bool:
348
+ """Put the cursor here on a press, and do it on a second one."""
349
+ if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
350
+ if self.here:
351
+ self.page.act(self.action, host)
352
+ else:
353
+ host.focus(self)
354
+ return True
355
+ return False
356
+
357
+
358
+ class ModelSettingsPage(Widget):
359
+ """Change everything about one model on one page, and save it all at once.
360
+
361
+ Shape::
362
+
363
+ GPT-6.1-sol (current) edited
364
+ ● address: write it as https://host/v1 <- why the last save was refused
365
+ ▸ Token ••••••••••••
366
+ API ◉ chat ○ responses ○ anthropic
367
+ Address http://tds.com:8787
368
+ Model id * gpt-6.1-sol
369
+ Shown name GPT-6.1-sol
370
+ Images ━━━━━━━━━● accepted
371
+ Web search ●───────── off
372
+ Search tool web_search_20260318
373
+ Temperature the provider's default
374
+ Context window 1m
375
+ Compress at 80
376
+
377
+ Use this model save, then switch to it now
378
+ Remove delete it from the file
379
+
380
+ Your key. The current one is never shown; leave it empty to keep it.
381
+ ↑ ↓ row · ← → set · enter save · esc back
382
+
383
+ The names are on the left and the values on the right: a typed row is an editor on a band, a switch is the rail
384
+ ``/statusline`` uses, with its knob, and a choice is its words with the chosen one marked. Up, Down and Tab move
385
+ between rows (they are no use inside a one-line editor), Left and Right and Space set a switch or a choice, and
386
+ Enter saves every row at once, as a form does: the answers go to ``on_save``, which answers ``None`` when it took
387
+ them or ``(key, sentence)`` when it did not. The sentence is shown under the title and the cursor goes to the row it
388
+ names, with every row as typed. The two actions at the bottom take the answers along when ``saves`` says so, and the
389
+ page scrolls when the screen is too short for every row.
390
+
391
+ Args:
392
+ title: The model's name.
393
+ settings: The rows, top to bottom.
394
+ actions: The things to do with the model, under the rows.
395
+ on_save: Receives the answers when Enter is pressed on a setting.
396
+ on_action: Receives the action chosen and the answers.
397
+ on_cancel: Called on Escape.
398
+ """
399
+
400
+ KEYS = f"{ARROW_UP} {ARROW_DOWN} row {SEPARATOR} \u2190 \u2192 set {SEPARATOR} enter save {SEPARATOR} esc back"
401
+
402
+ def __init__(
403
+ self,
404
+ title: str,
405
+ settings: Sequence[Setting],
406
+ actions: Sequence[Action] = (),
407
+ *,
408
+ on_save: Callable[[dict[str, Answer]], tuple[str, str] | None],
409
+ on_action: Callable[[Action, dict[str, Answer]], tuple[str, str] | None],
410
+ on_cancel: Callable[[], None],
411
+ ) -> None:
412
+ """Build the rows with the cursor in the first."""
413
+ super().__init__()
414
+ if not settings:
415
+ raise ValueError("A model page needs at least one setting")
416
+ self.title = title
417
+ self.settings = tuple(settings)
418
+ self.on_save = on_save
419
+ self.on_action = on_action
420
+ self.on_cancel = on_cancel
421
+ #: Why the last save was refused; empty until one is, and again as soon as the reader changes a row.
422
+ self.refusal = ""
423
+ #: The first visible slot of the list, so a screen too short for it still shows the row the cursor is in.
424
+ self.top = 0
425
+ self._rows: dict[str, _Row] = {}
426
+ slots: list[_Row | None] = []
427
+ for setting in self.settings:
428
+ row: _Row = _TextRow(self, setting) if setting.kind in ("text", "secret") else _RailRow(self, setting)
429
+ self._rows[setting.key] = row
430
+ slots.append(row)
431
+ if actions:
432
+ slots.append(None) # a blank row between the settings and what can be done with them
433
+ for action in actions:
434
+ row = _ActionRow(self, action)
435
+ self._rows[action.key] = row
436
+ slots.append(row)
437
+ self._slots: list[_Row | None] = slots
438
+ self._order: list[_Row] = [slot for slot in slots if slot is not None]
439
+ self._start = self.values
440
+
441
+ # -- tree ------------------------------------------------------------------------
442
+ @property
443
+ def children(self) -> tuple[Widget, ...]:
444
+ """Expose every row, whether it is on screen or not."""
445
+ return tuple(self._order)
446
+
447
+ @property
448
+ def focusable(self) -> bool:
449
+ """Leave the cursor to the rows."""
450
+ return False
451
+
452
+ # -- the answers -----------------------------------------------------------------
453
+ @property
454
+ def values(self) -> dict[str, Answer]:
455
+ """Return every setting's answer by key."""
456
+ answers: dict[str, Answer] = {}
457
+ for setting in self.settings:
458
+ row = self._rows[setting.key]
459
+ if isinstance(row, (_TextRow, _RailRow)):
460
+ answers[setting.key] = row.answer
461
+ return answers
462
+
463
+ @property
464
+ def changed(self) -> bool:
465
+ """Return whether any row is anything but how it started."""
466
+ return self.values != self._start
467
+
468
+ def edited(self) -> None:
469
+ """Forget the refusal once the reader starts correcting it."""
470
+ self.refusal = ""
471
+
472
+ def editor(self, key: str) -> LineArea:
473
+ """Return the editor of a typed row.
474
+
475
+ Raises:
476
+ KeyError: When the key is not a typed row's.
477
+ """
478
+ row = self._rows.get(key)
479
+ if not isinstance(row, _TextRow):
480
+ raise KeyError(key)
481
+ return row.area
482
+
483
+ def focus_key(self) -> str | None:
484
+ """Return the key of the row the cursor is in, if it is in one."""
485
+ return next((key for key, row in self._rows.items() if row.here), None)
486
+
487
+ # -- geometry --------------------------------------------------------------------
488
+ def _view(self) -> int:
489
+ """Return how many rows of the list the panel has room for."""
490
+ return max(1, self.rect.height - CHROME_ROWS)
491
+
492
+ def _reveal(self) -> None:
493
+ """Scroll the list the least that shows the row the cursor is in."""
494
+ view = self._view()
495
+ here = next((index for index, row in enumerate(self._slots) if row is not None and row.here), None)
496
+ if here is not None:
497
+ if here < self.top:
498
+ self.top = here
499
+ if here >= self.top + view:
500
+ self.top = here - view + 1
501
+ self.top = max(0, min(self.top, len(self._slots) - view))
502
+
503
+ def _place(self) -> None:
504
+ """Give the rows on screen a line each, and the others none."""
505
+ for hidden in self._order:
506
+ hidden.layout(Rect(self.rect.x, self.rect.y, 0, 0))
507
+ first = self.rect.y + 2
508
+ for offset, slot in enumerate(self._slots[self.top : self.top + self._view()]):
509
+ if slot is not None:
510
+ slot.layout(Rect(self.rect.x, first + offset, self.rect.width, 1))
511
+
512
+ def layout(self, rect: Rect) -> None:
513
+ """Place the rows that fit."""
514
+ super().layout(rect)
515
+ self._reveal()
516
+ self._place()
517
+
518
+ # -- painting --------------------------------------------------------------------
519
+ def render(self, canvas: Canvas) -> None:
520
+ """Fill the panel, then draw the title, the refusal, the rows on screen, the explanation and the keys."""
521
+ if self.rect.empty:
522
+ return
523
+ theme = self.theme
524
+ surface = theme.surface_alt
525
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
526
+ self._reveal()
527
+ self._place()
528
+ x = self.rect.x + MARGIN
529
+ room = max(0, self.rect.width - 2 * MARGIN)
530
+ muted = Style(foreground=theme.muted, background=surface)
531
+ canvas.draw_text(
532
+ x, self.rect.y, truncate(self.title, room), Style(foreground=theme.text, background=surface, bold=True)
533
+ )
534
+ if self.changed:
535
+ tag = "edited"
536
+ start = self.rect.right - MARGIN - display_width(tag)
537
+ if start > x + display_width(self.title) + 2:
538
+ canvas.draw_text(start, self.rect.y, tag, Style(foreground=theme.accent, background=surface, bold=True))
539
+ if self.refusal:
540
+ canvas.draw_text(
541
+ x,
542
+ self.rect.y + 1,
543
+ truncate(self.refusal, room),
544
+ Style(foreground=theme.warning, background=surface),
545
+ max_width=room,
546
+ )
547
+ for row in self._order:
548
+ row.render(canvas)
549
+ # A mark at the edge says there is more list than the screen could give it.
550
+ first = self.rect.y + 2
551
+ if self.top > 0:
552
+ canvas.draw_text(self.rect.right - MARGIN, first, "\u25b2", muted)
553
+ if self.top + self._view() < len(self._slots):
554
+ canvas.draw_text(self.rect.right - MARGIN, first + self._view() - 1, "\u25bc", muted)
555
+ canvas.draw_text(x, self.rect.bottom - 2, truncate(self._explanation(), room), muted, max_width=room)
556
+ canvas.draw_text(x, self.rect.bottom - 1, truncate(self.KEYS, room), muted, max_width=room)
557
+
558
+ def _explanation(self) -> str:
559
+ """Return the sentence about the row the cursor is in, or nothing when it is in none."""
560
+ key = self.focus_key()
561
+ return "" if key is None else self._rows[key].hint
562
+
563
+ def cursor(self) -> Point | None:
564
+ """Forward the cursor of the editor that has it."""
565
+ for row in self._order:
566
+ if isinstance(row, _TextRow) and row.here:
567
+ return row.cursor()
568
+ return None
569
+
570
+ # -- keys ------------------------------------------------------------------------
571
+ def refuse(self, host: Host, key: str, reason: str) -> None:
572
+ """Say why nothing was saved and put the cursor where it can be fixed."""
573
+ self.refusal = reason
574
+ row = self._rows.get(key)
575
+ if row is not None:
576
+ host.focus(row.target)
577
+
578
+ def _required(self, host: Host) -> bool:
579
+ """Refuse, and say so, when a required row is empty; return whether the page was refused."""
580
+ values = self.values
581
+ for setting in self.settings:
582
+ if setting.required and not values[setting.key]:
583
+ self.refuse(host, setting.key, f"{setting.label.lower()} is required")
584
+ return True
585
+ return False
586
+
587
+ def save(self, host: Host) -> None:
588
+ """Save every row, unless a required one is empty or the owner refuses."""
589
+ if self._required(host):
590
+ return
591
+ refused = self.on_save(self.values)
592
+ if refused is not None:
593
+ self.refuse(host, *refused)
594
+
595
+ def act(self, action: Action, host: Host) -> None:
596
+ """Do one of the actions, with the rows' answers when it takes them."""
597
+ if action.saves and self._required(host):
598
+ return
599
+ refused = self.on_action(action, self.values)
600
+ if refused is not None:
601
+ self.refuse(host, *refused)
602
+
603
+ def _move(self, host: Host, step: int) -> None:
604
+ """Put the cursor in the next or the previous row, going round at either end."""
605
+ here = next((index for index, row in enumerate(self._order) if row.here), None)
606
+ index = 0 if here is None else (here + step) % len(self._order)
607
+ host.focus(self._order[index].target)
608
+ self._reveal()
609
+ self._place()
610
+
611
+ def capture_event(self, event: AnyEvent, host: Host) -> bool:
612
+ """Take Escape, Enter and the keys that move between rows before the row can."""
613
+ key = event_key(event)
614
+ if key == "escape":
615
+ self.on_cancel()
616
+ return True
617
+ if key == "enter":
618
+ row = next((row for row in self._order if row.here), None)
619
+ if isinstance(row, _ActionRow):
620
+ self.act(row.action, host)
621
+ else:
622
+ self.save(host)
623
+ return True
624
+ if key in ("tab", "down"):
625
+ self._move(host, 1)
626
+ return True
627
+ if key in ("backtab", "up"):
628
+ self._move(host, -1)
629
+ return True
630
+ return False
631
+
632
+ def handle(self, event: AnyEvent, host: Host) -> bool:
633
+ """Take the wheel over the title and the gaps, as it is taken over a row."""
634
+ return self.bubble_event(event, host)
635
+
636
+ def bubble_event(self, event: AnyEvent, host: Host) -> bool:
637
+ """Move the cursor a row with the wheel, which no row has a use for."""
638
+ if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
639
+ if event.action is MouseAction.SCROLL_UP:
640
+ self._move(host, -1)
641
+ return True
642
+ if event.action is MouseAction.SCROLL_DOWN:
643
+ self._move(host, 1)
644
+ return True
645
+ return False
646
+
647
+
648
+ def model_settings_rows(settings: int, actions: int) -> int:
649
+ """Return the height of a panel that shows every row: the rows, a blank before the actions, and the chrome."""
650
+ return CHROME_ROWS + settings + (actions + 1 if actions else 0)
651
+
652
+
653
+ __all__ = ["Action", "Answer", "ModelSettingsPage", "Setting", "model_settings_rows"]