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,247 @@
1
+ """The pet that stands on the conversation and can be dragged anywhere on it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from time import monotonic
7
+
8
+ from zettcode.plugin_api.pets import GLAD, IDLE, WORKING, Pet
9
+ from zettcode.tui import Canvas, Host, MouseAction, MouseEvent, Rect, TextLine, Widget
10
+
11
+ #: Columns left between the pet and the right edge of its ground at its resting place, the margin the
12
+ #: header, the status line and the composer keep.
13
+ MARGIN = 2
14
+
15
+ #: The least room the conversation must have for the pet to be drawn: columns, so that it does not take a
16
+ #: noticeable part of every line, and rows above and below it.
17
+ MIN_WIDTH = 80
18
+ ROOM_HIGH = 3
19
+
20
+ #: How long a click leaves the pet pleased, and how long each frame of a mood is held.
21
+ GLAD_SECONDS = 0.9
22
+ FRAME_SECONDS = 0.5
23
+
24
+
25
+ def paint_sprite(canvas: Canvas, lines: tuple[TextLine, ...], x: int, y: int) -> None:
26
+ """Paint a pet's cells with their top left corner at a cell, leaving the cells it does not cover as they were.
27
+
28
+ A cell the sprite leaves empty is skipped rather than written as a space, so whatever is under it shows
29
+ through; a cell with a colour for only one half takes the background that was already there.
30
+ """
31
+ for row, line in enumerate(lines):
32
+ column = x
33
+ for span in line.spans:
34
+ for character in span.text:
35
+ if character != " " or span.style.background is not None:
36
+ canvas.set_cell(column, y + row, character, span.style)
37
+ column += 1
38
+
39
+
40
+ class PetView(Widget):
41
+ """Draw a pet over the right-hand middle of its ground, and move it with the mouse.
42
+
43
+ Shape::
44
+
45
+ ┌ conversation ────────────────────────────────┐
46
+ │ ... │
47
+ │ ... ▄█▀▀▀█▄ │
48
+ │ ... ██▀█▀██ │ <- press on it and drag it anywhere
49
+ │ ... ▀▀▀▀▀ │ on its ground
50
+ │ ... │
51
+ ├ composer ────────────────────────────────────┤ <- which is part of the ground
52
+ └──────────────────────────────────────────────┘
53
+
54
+ The pet's place is kept as the fraction of the free room it stands at, left to right and top to bottom,
55
+ so it keeps its place when the terminal is resized or a panel opens over it. It
56
+ starts at the right edge, halfway down, or where it was last left: a drag that moved it reports the place
57
+ through ``on_moved``, and the next start passes it back as ``position``. The ground is what ``area`` says:
58
+ the shell gives it the conversation and the composer, so the pet can stand on the box the reader types in; a drag
59
+ cannot take it onto the bars. A panel is a screen above this one and covers it. A press that does not move is a
60
+ pat, and the pet is pleased for a moment.
61
+ It sleeps while nothing runs, wakes and goes about its business while a request does, and its cells that are
62
+ empty let the conversation show through. The wheel over it scrolls the conversation as it would anywhere else.
63
+
64
+ The pet is drawn only when the reader has not hidden it (``pet`` in ``/statusline``) and the
65
+ conversation is wide and tall enough to leave it room; otherwise it takes none and catches no clicks.
66
+
67
+ Args:
68
+ pet: The sprite and its moods.
69
+ area: The rectangle the pet may stand in, asked for on every layout.
70
+ shown: Whether the reader wants a pet at all.
71
+ busy: Whether a request is running.
72
+ forward: The widget a wheel turn over the pet is handed to.
73
+ position: Where the pet stood when it was last left, as the two shares described above; ``None`` starts
74
+ it at the right edge, halfway down.
75
+ on_moved: Called with the two shares when a drag that moved the pet ends, so the place can be remembered.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ pet: Pet,
81
+ *,
82
+ area: Callable[[], Rect],
83
+ shown: Callable[[], bool] = lambda: True,
84
+ busy: Callable[[], bool] = lambda: False,
85
+ forward: Widget | None = None,
86
+ position: tuple[float, float] | None = None,
87
+ on_moved: Callable[[float, float], None] | None = None,
88
+ ) -> None:
89
+ """Start where it was left, or at the right edge halfway down, resting."""
90
+ super().__init__()
91
+ self.pet = pet
92
+ self._area = area
93
+ self._shown = shown
94
+ self._busy = busy
95
+ self._forward = forward
96
+ self._on_moved = on_moved
97
+ #: Where the pet stands in the free room, from 0 (left, top) to 1 (right, bottom).
98
+ self.fx, self.fy = position if position is not None else (1.0, 0.5)
99
+ #: Where inside the pet the pointer holds it, while a press is down. It follows the pet and not the press:
100
+ #: when the area stops the pet, the pointer goes on and this grows by what the area took, so the way back
101
+ #: starts at once instead of after the whole distance the pointer went past the edge.
102
+ self._grab: tuple[int, int] | None = None
103
+ #: The cell the press landed on, so "did the pointer move" is a question about the pointer and not about
104
+ #: whether the area let the pet follow it.
105
+ self._pressed_at: tuple[int, int] | None = None
106
+ self._moved = False
107
+ self._glad_since: float | None = None
108
+ #: The mood and frame the last paint drew, so a tick can tell whether anything moved.
109
+ self._drawn: tuple[str, int] | None = None
110
+ self._token = f"pet:{id(self)}"
111
+
112
+ @property
113
+ def selects_text(self) -> bool:
114
+ """Own the drag that starts on the pet, so moving it does not mark the cells it crosses for copying."""
115
+ return True
116
+
117
+ @property
118
+ def visible(self) -> bool:
119
+ """Return whether the pet is wanted and the conversation leaves it room."""
120
+ if not self._shown():
121
+ return False
122
+ area = self._area()
123
+ return area.width >= MIN_WIDTH and area.height >= self.pet.height + ROOM_HIGH
124
+
125
+ def set_pet(self, pet: Pet) -> None:
126
+ """Swap the sprite for another pet's, which stands where the last one did (as a share of the room)."""
127
+ if pet is self.pet:
128
+ return
129
+ self.pet = pet
130
+ self._drawn = None
131
+ if self.app is not None:
132
+ self.app.request_layout()
133
+
134
+ @property
135
+ def mood(self) -> str:
136
+ """Return the mood the pet is in: pleased after a pat, awake while a request runs, else asleep."""
137
+ if self._glad_since is not None:
138
+ return GLAD
139
+ return WORKING if self._busy() else IDLE
140
+
141
+ def _now(self) -> float:
142
+ return self.app.clock() if self.app is not None else monotonic()
143
+
144
+ def layout(self, rect: Rect) -> None:
145
+ """Stand where the fractions say inside the area; an empty rectangle when there is no room or no wish."""
146
+ super().layout(self._stand() if self.visible else Rect(0, 0, 0, 0))
147
+
148
+ def _stand(self) -> Rect:
149
+ """Return the rectangle the pet occupies for its fractions, inside the area."""
150
+ area = self._area()
151
+ free_x = max(0, area.width - self.pet.width - MARGIN)
152
+ free_y = max(0, area.height - self.pet.height)
153
+ return Rect(area.x + round(self.fx * free_x), area.y + round(self.fy * free_y), self.pet.width, self.pet.height)
154
+
155
+ def _picture(self) -> tuple[str, int]:
156
+ """Return the mood and the frame of it that is showing: the frames take turns on the clock.
157
+
158
+ A pat counts its frames from the pat, so the pet always begins pleased; any other mood counts from
159
+ zero. With reduced motion the pet keeps the first frame of its mood: the beat is decoration.
160
+ """
161
+ mood = self.mood
162
+ frames = self.pet.frames(mood)
163
+ if frames == 1 or (self.app is not None and self.app.reduced_motion):
164
+ return mood, 0
165
+ origin = self._glad_since if self._glad_since is not None else 0.0
166
+ return mood, int((self._now() - origin) / FRAME_SECONDS) % frames
167
+
168
+ def on_tick(self) -> bool:
169
+ """End a pat that has lasted long enough, and say whether the picture is not the one last drawn."""
170
+ if self._glad_since is not None and self._now() - self._glad_since >= GLAD_SECONDS:
171
+ self._glad_since = None
172
+ self._release()
173
+ if self.visible == self.rect.empty:
174
+ # Wanted and not placed, or placed and no longer wanted: the layout has to hear of it.
175
+ if self.app is not None:
176
+ self.app.request_layout()
177
+ return True
178
+ return not self.rect.empty and self._picture() != self._drawn
179
+
180
+ def render(self, canvas: Canvas) -> None:
181
+ """Paint the frame for the mood, leaving the cells the sprite does not cover as they were."""
182
+ if self.rect.empty or not self.visible:
183
+ return
184
+ self._drawn = self._picture()
185
+ paint_sprite(canvas, self.pet.lines(*self._drawn), self.rect.x, self.rect.y)
186
+
187
+ def handle(self, event, host: Host) -> bool:
188
+ """Take hold on a press, follow the pointer while it is held, and pat the pet if it never moved."""
189
+ if not isinstance(event, MouseEvent):
190
+ return False
191
+ if event.action in (MouseAction.SCROLL_UP, MouseAction.SCROLL_DOWN):
192
+ return self._forward is not None and self._forward.handle(event, host)
193
+ if event.action is MouseAction.DOWN and event.button == 0:
194
+ self._grab = (event.x - self.rect.x, event.y - self.rect.y)
195
+ self._pressed_at = (event.x, event.y)
196
+ self._moved = False
197
+ return True
198
+ if self._grab is None:
199
+ return False
200
+ if event.action is MouseAction.MOVE:
201
+ if (event.x, event.y) != self._pressed_at:
202
+ self._moved = True
203
+ self._drag_to(event.x, event.y)
204
+ return True
205
+ if event.action is MouseAction.UP:
206
+ if not self._moved:
207
+ self._pat()
208
+ elif self._on_moved is not None:
209
+ self._on_moved(self.fx, self.fy)
210
+ self._grab = None
211
+ self._pressed_at = None
212
+ return True
213
+ return False
214
+
215
+ def _drag_to(self, pointer_x: int, pointer_y: int) -> None:
216
+ """Put the pet under the pointer, as far as the area lets it, and remember where it stands as fractions.
217
+
218
+ What the area takes off the move is added to the grab, so the pointer is again over the same place on the
219
+ pet that it would be if the pet had reached it: coming back from past the edge moves the pet at the first
220
+ cell, and not after the pointer has crossed everything it overshot.
221
+ """
222
+ area = self._area()
223
+ grab_x, grab_y = self._grab or (0, 0)
224
+ free_x = max(0, area.width - self.pet.width - MARGIN)
225
+ free_y = max(0, area.height - self.pet.height)
226
+ if free_x:
227
+ self.fx = min(1.0, max(0.0, (pointer_x - grab_x - area.x) / free_x))
228
+ if free_y:
229
+ self.fy = min(1.0, max(0.0, (pointer_y - grab_y - area.y) / free_y))
230
+ self.rect = self._stand()
231
+ self._grab = (pointer_x - self.rect.x, pointer_y - self.rect.y)
232
+
233
+ def _pat(self) -> None:
234
+ """Please the pet for a moment; the clock ends it, so it passes without any further input."""
235
+ self._glad_since = self._now()
236
+ if self.app is not None:
237
+ self.app.scheduler.animate(self._token)
238
+ self.app.invalidate()
239
+
240
+ def _release(self) -> None:
241
+ """Give the frame loop back."""
242
+ if self.app is not None:
243
+ self.app.scheduler.animate(self._token, active=False)
244
+
245
+ def on_unmount(self) -> None:
246
+ """Release the frame loop, so a pet put away mid-pat stops costing frames."""
247
+ self._release()
@@ -0,0 +1,347 @@
1
+ """The bottom panel ``/pet`` opens: every pet by name, with the marked one on a stage at the right."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from time import monotonic
7
+
8
+ from zettcode.plugin_api.panels import pressed
9
+ from zettcode.plugin_api.pets import GLAD, IDLE, WORKING, Pet, PetCatalog
10
+ from zettcode.tui import ARROW_DOWN, ARROW_UP, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
11
+ from zettcode.tui.render import Style, blend, display_width, truncate
12
+
13
+ from .pet import paint_sprite
14
+
15
+ #: Rows the panel needs besides the list: a blank, the title and a blank above it, and the keys below.
16
+ CHROME = 4
17
+
18
+ #: The first row of the list, counted from the top of the panel.
19
+ LIST_TOP = 3
20
+
21
+ #: The stage the marked pet stands on, at the right: columns, and rows. A pet is up to seven rows tall and stands
22
+ #: on the floor line whatever its height, so the name and the mood under it do not move as the mark does: a pad,
23
+ #: the sprite's seven, the floor, a blank, the name and the mood.
24
+ BOX_WIDTH = 24
25
+ SPRITE_ROWS = 7
26
+ BOX_HEIGHT = 12
27
+
28
+ #: The least columns the panel must have for the stage to be drawn beside the list rather than not at all.
29
+ MIN_WIDTH_FOR_BOX = 62
30
+
31
+ #: The rows of the screen the panel leaves to the conversation above it.
32
+ LEFT_FOR_CONVERSATION = 8
33
+
34
+ #: Columns a pet's name takes in the list, and the colour chip before it.
35
+ NAME_WIDTH = 11
36
+ CHIP = 3
37
+
38
+ #: How long the stage holds each step of the mood it shows the marked pet in.
39
+ STEP_SECONDS = 0.6
40
+
41
+ #: What the stage shows, step after step: the mood, the frame of it, and the word and colour role it is told by.
42
+ SEQUENCE: tuple[tuple[str, int, str], ...] = (
43
+ (IDLE, 0, "asleep"),
44
+ (IDLE, 0, "asleep"),
45
+ (WORKING, 0, "at work"),
46
+ (WORKING, 1, "at work"),
47
+ (WORKING, 0, "at work"),
48
+ (WORKING, 1, "at work"),
49
+ (GLAD, 0, "pleased"),
50
+ (GLAD, 1, "pleased"),
51
+ )
52
+
53
+ #: The three moods in the order the steps go through them, for the row of dots under the name.
54
+ MOODS: tuple[tuple[str, str], ...] = ((IDLE, "asleep"), (WORKING, "at work"), (GLAD, "pleased"))
55
+
56
+
57
+ def pet_rows(count: int, height: int) -> int:
58
+ """Return the rows to ask for: the whole list when the screen has room for it, else what it can spare."""
59
+ wanted = CHROME + max(count, BOX_HEIGHT)
60
+ return max(CHROME + 3, min(wanted, height - LEFT_FOR_CONVERSATION))
61
+
62
+
63
+ class PetPage(Widget):
64
+ """Choose a pet: the list at the left, the marked pet going through its moods on a stage at the right.
65
+
66
+ Shape::
67
+
68
+ Pet 7 pets
69
+
70
+ ▸ ■■■ cat ● a green cat in a bell collar; it watches the work ┌──────────────────────┐
71
+ ■■■ dog a tan dog; it looks along the work and wags its ears │ (sprite) │
72
+ ■■■ tiger a tiger cub; it keeps watch while a request runs │ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │
73
+ ... │ cat │
74
+ ↑ ↓ pet · enter choose · esc cancel │ ● at work ○ ○ │
75
+ └──────────────────────┘
76
+
77
+ The chip before each name is the three colours the pet is mostly made of, read from its own sprite, and the
78
+ stage is tinted with the first. Up and Down move the mark, and every move puts the marked pet over the
79
+ conversation too, where it will stand, so it can be judged there as well as on the stage; Escape puts the one
80
+ that was there back. Enter chooses. The stage shows the marked pet asleep, at work and pleased in turn, from
81
+ the clock, so it passes by itself and costs nothing once the panel is put away.
82
+
83
+ Args:
84
+ catalog: Every pet that can be asked for.
85
+ current: The name of the pet that is out now; the mark starts on it and the list marks it.
86
+ on_move: Called with the name of a pet when the mark comes to rest on it.
87
+ on_select: Called with the name of the pet Enter chose.
88
+ on_cancel: Called when the reader backs out.
89
+ """
90
+
91
+ HINT = f"{ARROW_UP} {ARROW_DOWN} pet {SEPARATOR} enter choose {SEPARATOR} esc cancel"
92
+
93
+ def __init__(
94
+ self,
95
+ catalog: PetCatalog,
96
+ current: str,
97
+ *,
98
+ on_move: Callable[[str], None],
99
+ on_select: Callable[[str], None],
100
+ on_cancel: Callable[[], None],
101
+ ) -> None:
102
+ """Start with the mark on the pet that is out now, or on the first when it is not one of these."""
103
+ super().__init__()
104
+ self.catalog = catalog
105
+ self.names = catalog.names()
106
+ self.notes = catalog.notes()
107
+ self.current = current
108
+ self.on_move = on_move
109
+ self.on_select = on_select
110
+ self.on_cancel = on_cancel
111
+ self.mark = self.names.index(current) if current in self.names else 0
112
+ #: The first visible row of the list.
113
+ self.top = 0
114
+ self._loaded: dict[str, Pet | None] = {}
115
+ self._token = f"pet-panel:{id(self)}"
116
+ self._began = monotonic()
117
+ self._drawn: int | None = None
118
+
119
+ @property
120
+ def focusable(self) -> bool:
121
+ """Take focus when the shell pushes the page, so the keys reach it."""
122
+ return True
123
+
124
+ @property
125
+ def marked(self) -> str:
126
+ """Return the name of the pet the mark is on."""
127
+ return self.names[self.mark]
128
+
129
+ def _now(self) -> float:
130
+ return self.app.clock() if self.app is not None else monotonic()
131
+
132
+ def on_mount(self) -> None:
133
+ """Start the clock the stage moves by, and count the steps from now."""
134
+ self._began = self._now()
135
+ if self.app is not None and not self.app.reduced_motion:
136
+ self.app.scheduler.animate(self._token)
137
+
138
+ def on_unmount(self) -> None:
139
+ """Release the frame loop, so a panel put away stops costing frames."""
140
+ if self.app is not None:
141
+ self.app.scheduler.animate(self._token, active=False)
142
+
143
+ def _step(self) -> int:
144
+ """Return which step of the sequence the stage is on; a still stage shows the pet at work."""
145
+ if self.app is not None and self.app.reduced_motion:
146
+ return 2
147
+ return int((self._now() - self._began) / STEP_SECONDS) % len(SEQUENCE)
148
+
149
+ def on_tick(self) -> bool:
150
+ """Say whether the stage shows a different step from the one last drawn."""
151
+ return self._step() != self._drawn
152
+
153
+ def _pet(self, name: str) -> Pet | None:
154
+ """Return the pet called ``name``, built once; ``None`` when it could not be built."""
155
+ if name not in self._loaded:
156
+ try:
157
+ self._loaded[name] = self.catalog.load(name)
158
+ except ValueError:
159
+ self._loaded[name] = None
160
+ return self._loaded[name]
161
+
162
+ def capture_event(self, event, host: Host) -> bool:
163
+ """Take Escape before the focused widget can."""
164
+ if pressed(event) == "escape":
165
+ self.on_cancel()
166
+ return True
167
+ return False
168
+
169
+ def handle(self, event, host: Host) -> bool:
170
+ """Move the mark with the arrows, and choose with Enter."""
171
+ if isinstance(event, MouseEvent):
172
+ return self._handle_mouse(event)
173
+ match pressed(event):
174
+ case "up" | "backtab":
175
+ self._wrap(-1)
176
+ case "down" | "tab":
177
+ self._wrap(1)
178
+ case "pageup":
179
+ self._move(self.mark - max(1, self._view() - 1))
180
+ case "pagedown":
181
+ self._move(self.mark + max(1, self._view() - 1))
182
+ case "home":
183
+ self._move(0)
184
+ case "end":
185
+ self._move(len(self.names) - 1)
186
+ case "enter":
187
+ self.on_select(self.marked)
188
+ case _:
189
+ return False
190
+ return True
191
+
192
+ def _handle_mouse(self, event: MouseEvent) -> bool:
193
+ """Mark the pet under a click, and choose it when it was already marked; the wheel moves the mark."""
194
+ if event.action is MouseAction.SCROLL_UP:
195
+ self._move(self.mark - 1)
196
+ return True
197
+ if event.action is MouseAction.SCROLL_DOWN:
198
+ self._move(self.mark + 1)
199
+ return True
200
+ if event.action is not MouseAction.DOWN:
201
+ return False
202
+ offset = event.y - (self.rect.y + LIST_TOP)
203
+ index = self.top + offset
204
+ if not 0 <= offset < self._view() or index >= len(self.names):
205
+ return False
206
+ if index == self.mark:
207
+ self.on_select(self.marked)
208
+ else:
209
+ self._move(index)
210
+ return True
211
+
212
+ def _wrap(self, step: int) -> None:
213
+ """Move the mark one pet, going round: up from the first lands on the last, down from the last on the first."""
214
+ self._move((self.mark + step) % len(self.names))
215
+
216
+ def _move(self, index: int) -> None:
217
+ """Put the mark on a pet, within the list, and tell whoever shows the pet over the conversation."""
218
+ index = max(0, min(len(self.names) - 1, index))
219
+ if index != self.mark:
220
+ self.mark = index
221
+ self.on_move(self.marked)
222
+
223
+ def _view(self) -> int:
224
+ """Return how many rows of the list the panel has room for."""
225
+ return max(1, self.rect.height - CHROME)
226
+
227
+ def _reveal(self) -> None:
228
+ """Scroll the list the least that shows the marked pet."""
229
+ view = self._view()
230
+ if self.mark < self.top:
231
+ self.top = self.mark
232
+ if self.mark >= self.top + view:
233
+ self.top = self.mark - view + 1
234
+ self.top = max(0, min(self.top, len(self.names) - view))
235
+
236
+ def render(self, canvas: Canvas) -> None:
237
+ """Fill the panel, then draw the title, the list, the stage and the keys."""
238
+ if self.rect.empty:
239
+ return
240
+ theme = self.theme
241
+ surface = theme.surface_alt
242
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
243
+ x = self.rect.x + 2
244
+ room = max(0, self.rect.width - 4)
245
+ muted = Style(foreground=theme.muted, background=surface)
246
+ canvas.draw_text(x, self.rect.y + 1, "Pet", Style(foreground=theme.text, background=surface, bold=True))
247
+ count = f"{len(self.names)} pets"
248
+ canvas.draw_text(self.rect.right - 2 - display_width(count), self.rect.y + 1, count, muted)
249
+ boxed = self.rect.width >= MIN_WIDTH_FOR_BOX and self._view() >= BOX_HEIGHT
250
+ list_right = self.rect.right - 2 - (BOX_WIDTH + 2 if boxed else 0)
251
+ self._reveal()
252
+ self._draw_list(canvas, x, list_right)
253
+ if boxed:
254
+ self._draw_stage(canvas, self.rect.right - 2 - BOX_WIDTH, self.rect.y + LIST_TOP)
255
+ canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
256
+
257
+ def _draw_list(self, canvas: Canvas, x: int, right: int) -> None:
258
+ """Draw the visible rows: a marker, the colour chip, the name, a dot for the one out now, and its note."""
259
+ theme = self.theme
260
+ surface = theme.surface_alt
261
+ for offset in range(self._view()):
262
+ index = self.top + offset
263
+ if index >= len(self.names):
264
+ break
265
+ y = self.rect.y + LIST_TOP + offset
266
+ name = self.names[index]
267
+ marked = index == self.mark
268
+ style = Style(foreground=theme.text if marked else theme.muted, background=surface, bold=marked)
269
+ if marked:
270
+ canvas.draw_text(x, y, "\u25b8", Style(foreground=theme.accent, background=surface, bold=True))
271
+ pet = self._pet(name)
272
+ colours = pet.swatch(CHIP) if pet is not None else ()
273
+ for slot in range(CHIP):
274
+ # An unmarked row's chip is toned down towards the panel, so the marked one is the one in colour.
275
+ colour = colours[slot] if slot < len(colours) else theme.muted
276
+ shown = colour if marked else blend(colour, surface, 0.45)
277
+ canvas.draw_text(x + 2 + slot, y, "\u25a0", Style(foreground=shown, background=surface))
278
+ label = x + 2 + CHIP + 1
279
+ canvas.draw_text(label, y, truncate(name, NAME_WIDTH - 2), style, max_width=NAME_WIDTH - 2)
280
+ if name == self.current:
281
+ canvas.draw_text(
282
+ label + min(display_width(name), NAME_WIDTH - 2) + 1,
283
+ y,
284
+ "\u25cf",
285
+ Style(foreground=theme.accent, background=surface),
286
+ )
287
+ start = label + NAME_WIDTH
288
+ room = right - start
289
+ if room >= 12:
290
+ canvas.draw_text(
291
+ start,
292
+ y,
293
+ truncate(self.notes.get(name, ""), room),
294
+ Style(foreground=blend(theme.muted, surface, 0.15), background=surface),
295
+ max_width=room,
296
+ )
297
+ edge = Style(foreground=theme.muted, background=surface)
298
+ if self.top > 0:
299
+ canvas.draw_text(right - 1, self.rect.y + LIST_TOP, "\u25b2", edge)
300
+ if self.top + self._view() < len(self.names):
301
+ canvas.draw_text(right - 1, self.rect.y + LIST_TOP + self._view() - 1, "\u25bc", edge)
302
+
303
+ def _mood_colour(self, pose: str) -> str:
304
+ """Return the colour a mood is told in: quiet when asleep, the accent at work, the warm one when pleased."""
305
+ theme = self.theme
306
+ return {IDLE: theme.muted, WORKING: theme.accent, GLAD: theme.warning}[pose]
307
+
308
+ def _draw_stage(self, canvas: Canvas, x: int, y: int) -> None:
309
+ """Draw the marked pet on a stage tinted with its own colour, its name, and the mood it is in."""
310
+ theme = self.theme
311
+ pet = self._pet(self.marked)
312
+ tint = pet.swatch(1)[0] if pet is not None and pet.swatch(1) else theme.accent
313
+ stage = blend(theme.surface_alt, tint, 0.16)
314
+ floor = blend(theme.surface_alt, tint, 0.38)
315
+ canvas.fill(x, y, BOX_WIDTH, BOX_HEIGHT, Style(background=stage))
316
+ step = self._step()
317
+ self._drawn = step
318
+ if pet is None:
319
+ message = "could not be built"
320
+ canvas.draw_text(
321
+ x + (BOX_WIDTH - len(message)) // 2,
322
+ y + BOX_HEIGHT // 2,
323
+ message,
324
+ Style(foreground=theme.muted, background=stage, italic=True),
325
+ )
326
+ return
327
+ pose, frame, word = SEQUENCE[step]
328
+ floor_y = y + 1 + SPRITE_ROWS
329
+ paint_sprite(canvas, pet.lines(pose, frame), x + (BOX_WIDTH - pet.width) // 2, floor_y - pet.height)
330
+ # The floor the pet stands on: one row of a deeper tint, as wide as the stage.
331
+ canvas.fill(x, floor_y, BOX_WIDTH, 1, Style(background=floor))
332
+ name_y = floor_y + 2
333
+ canvas.draw_text(
334
+ x + (BOX_WIDTH - display_width(pet.name)) // 2,
335
+ name_y,
336
+ pet.name,
337
+ Style(foreground=theme.text, background=stage, bold=True),
338
+ )
339
+ # The three moods as a row of lit and unlit dots; the one the pet is in carries its colour and its word.
340
+ parts = [(f"\u25cf {word}" if mood == pose else "\u25cb", mood) for mood, _ in MOODS]
341
+ widths = [display_width(text) for text, _ in parts]
342
+ column = x + (BOX_WIDTH - sum(widths) - 2 * (len(parts) - 1)) // 2
343
+ for (text, mood), width in zip(parts, widths, strict=True):
344
+ lit = mood == pose
345
+ colour = self._mood_colour(mood) if lit else blend(theme.muted, stage, 0.5)
346
+ canvas.draw_text(column, name_y + 1, text, Style(foreground=colour, background=stage, bold=lit))
347
+ column += width + 2