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,290 @@
1
+ """The shell's two bars: what the header and the status line draw.
2
+
3
+ The bars are plugin segments, so this part owns only the generic loop and the
4
+ snapshot a segment builder reads. The builtin rows themselves ship as a plugin,
5
+ which is what leaves the shell with one renderer rather than one per row.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import replace
11
+
12
+ from zettcode.plugin_api.state import (
13
+ ActivityState,
14
+ DisplayState,
15
+ ModelState,
16
+ SessionState,
17
+ ShellContext,
18
+ UiRegion,
19
+ UiSide,
20
+ )
21
+ from zettcode.presenters.statusline import Row
22
+ from zettcode.tui import HEADER, SEPARATOR, Span, Style, TextLine
23
+ from zettcode.tui.render import blend, display_width
24
+ from zettcode.wording import UNTITLED_SESSION
25
+
26
+ from ..agent.conversation.shimmer import ShimmerFrame
27
+ from .shell import ShellState
28
+ from .turns import plan_rows
29
+ from .widgets.effort_page import heat_colour
30
+
31
+ #: The columns the status line keeps empty between its two sides, and the parts it gives up first, in order, when
32
+ #: the left side does not fit beside the keys.
33
+ STATUS_GAP = 2
34
+ SPARED_FIRST = ("speed", "cache", "tokens")
35
+
36
+
37
+ class BarsMixin(ShellState):
38
+ """Paint each row side from the segments the plugins registered."""
39
+
40
+ def _terminal_title(self) -> str:
41
+ """Keep the brand marker and app name when the active session changes."""
42
+ return f"{HEADER} zettcode {SEPARATOR} {self._session_title or UNTITLED_SESSION}"
43
+
44
+ def _header_left(self) -> TextLine:
45
+ """Return the header's left side, whatever the plugins put there.
46
+
47
+ While the show for a switch to the top of the scale plays, the light crossing the header goes over
48
+ this side too, so the whole row glints and not only the corner that changed.
49
+ """
50
+ line = self._side("header", "left")
51
+ frame = self._effort_gauge()
52
+ return self._glint(line, frame) if isinstance(frame, ShimmerFrame) else line
53
+
54
+ def _glint(self, line: TextLine, frame: ShimmerFrame) -> TextLine:
55
+ """Lay the light of the show over a line drawn from the left edge, character by character."""
56
+ theme = self.app.theme
57
+ halo = blend(blend(theme.warning, theme.text, 0.18), theme.text, 0.4)
58
+ spans: list[Span] = []
59
+ column = 0
60
+ for span in line.spans:
61
+ for character in span.text:
62
+ glow, core = frame.light(frame.reach - 1 - column)
63
+ colour = span.style.foreground or theme.muted
64
+ if glow > 0.0:
65
+ colour = blend(colour, halo, glow * 0.9 * frame.presence)
66
+ colour = blend(colour, theme.text, core * frame.presence)
67
+ bold = span.style.bold or core * frame.presence > 0.5
68
+ spans.append(Span(character, replace(span.style, foreground=colour, bold=bold)))
69
+ column += max(1, display_width(character))
70
+ return TextLine(tuple(spans), metadata=line.metadata)
71
+
72
+ def _header_right(self) -> TextLine:
73
+ """Return the header's right side: the plugins' segments, or the gauge.
74
+
75
+ A switch between effort levels owns this row for the second it animates:
76
+ the model and the gauge take the place of the segments a plugin
77
+ registered, which is a trade worth making for one second and the one
78
+ moment a reader changed something here.
79
+ """
80
+ gauge = self._effort_gauge()
81
+ model = self.model
82
+ if gauge is None or model is None:
83
+ # Nothing to animate the model and the gauge around: a machine with no model has no levels to switch
84
+ # between, so the segments a plugin registered stand.
85
+ return self._side("header", "right")
86
+ if isinstance(gauge, ShimmerFrame):
87
+ return self._shimmer_row(gauge, model.name)
88
+ theme = self.app.theme
89
+ muted = Style(foreground=theme.muted)
90
+ # The rail is the border colour brought toward the background, so an
91
+ # unreached cell reads as a groove rather than as a dim line of text.
92
+ groove = blend(theme.muted, theme.surface, 0.55)
93
+ spans: list[Span] = [
94
+ Span(model.name, muted),
95
+ Span(f" {SEPARATOR} ", muted),
96
+ ]
97
+ away = 1.0 - gauge.presence
98
+ # The groove fades with the gauge, so the last thing to leave is not a rail
99
+ # standing alone in an otherwise plain header.
100
+ rail_colour = blend(groove, theme.surface, away)
101
+ rail = Style(foreground=rail_colour)
102
+ for cell in gauge.cells:
103
+ if cell.lit:
104
+ colour = blend(self._heat(cell.heat), theme.text, cell.glow * 0.8)
105
+ # What is missing of the gauge is made up by the groove it sits in,
106
+ # so it fades into the rail rather than into nothing.
107
+ spans.append(Span(cell.char, Style(foreground=blend(colour, rail_colour, away))))
108
+ else:
109
+ spans.append(Span(cell.char, rail))
110
+ word = self._heat(gauge.word_heat)
111
+ spans.append(Span(" ", muted))
112
+ for character, glow in zip(gauge.word, gauge.word_glow, strict=True):
113
+ colour = blend(word, theme.text, glow * 0.9)
114
+ # The word returns to the header's own grey, which is what it is
115
+ # when the gauge is gone: the last frame and the next are the same colour.
116
+ spans.append(Span(character, Style(foreground=blend(colour, theme.muted, away))))
117
+ return TextLine(tuple(spans))
118
+
119
+ def _shimmer_row(self, frame: ShimmerFrame, name: str) -> TextLine:
120
+ """Paint the show a switch to the top of the scale plays: light crossing the row, right to left.
121
+
122
+ The frame says where the light is and what it has lit; the colours are chosen here, from the theme
123
+ like every other colour in the header. A lit column is gold, and at ``ultra`` the gauge runs on from
124
+ gold into the error colour toward its dear end. The halo is a pale gold and the core inside it takes
125
+ the text colour, which is the brightest the palette has (on a light theme that is the darkest ink,
126
+ so the glint reads as a glint there too). Everything returns to the resting header by what is
127
+ missing of the frame's presence, as the ordinary gauge does.
128
+ """
129
+ theme = self.app.theme
130
+ muted = theme.muted
131
+ groove = blend(theme.muted, theme.surface, 0.55)
132
+ away = 1.0 - frame.presence
133
+ ultra = frame.word == "ultra"
134
+ gold = blend(theme.warning, theme.text, 0.18)
135
+ halo = blend(gold, theme.text, 0.4)
136
+ last = max(1, frame.cells - 1)
137
+
138
+ def lit(steady: str, column: int, rest: str, *, ignited: bool | None = None) -> str:
139
+ colour = steady if (frame.ignited[column] if ignited is None else ignited) else rest
140
+ colour = blend(colour, halo, frame.glow[column] * 0.9)
141
+ colour = blend(colour, theme.text, frame.core[column])
142
+ if frame.ignited[column]:
143
+ colour = blend(colour, theme.text, frame.glitter[column] * 0.8)
144
+ return blend(colour, rest, away)
145
+
146
+ spans: list[Span] = []
147
+ column = 0
148
+ for character in f"{name} {SEPARATOR} ":
149
+ if column >= frame.lead:
150
+ break
151
+ steady = blend(muted, gold, 0.3)
152
+ spans.append(Span(character, Style(foreground=lit(steady, column, muted))))
153
+ column += max(1, display_width(character))
154
+ for index in range(frame.cells):
155
+ column = frame.lead + index
156
+ if index < frame.lit_cells and frame.ignited[column]:
157
+ steady = heat_colour(theme, index / last)
158
+ steady = blend(steady, gold, 0.45)
159
+ if ultra:
160
+ steady = blend(steady, theme.error, 0.5 * index / last)
161
+ spans.append(Span("\u2501", Style(foreground=lit(steady, column, groove))))
162
+ else:
163
+ spans.append(Span("\u2500", Style(foreground=lit(groove, column, groove, ignited=False))))
164
+ word_colour = blend(heat_colour(theme, frame.heat), gold, 0.5)
165
+ if ultra:
166
+ word_colour = blend(word_colour, theme.error, 0.35)
167
+ word_start = frame.word_start
168
+ spans.append(Span(" ", Style(foreground=muted)))
169
+ spark_left = word_start - 2
170
+ spans.append(Span(frame.sparks[0], Style(foreground=lit(word_colour, spark_left, muted), bold=True)))
171
+ spans.append(Span(" ", Style(foreground=muted)))
172
+ for offset, character in enumerate(frame.word):
173
+ at = word_start + offset
174
+ spans.append(Span(character, Style(foreground=lit(word_colour, at, muted), bold=frame.ignited[at])))
175
+ spans.append(Span(" ", Style(foreground=muted)))
176
+ spark_right = frame.width - 1
177
+ spans.append(Span(frame.sparks[1], Style(foreground=lit(word_colour, spark_right, muted), bold=True)))
178
+ return TextLine(tuple(spans))
179
+
180
+ def _heat(self, heat: float) -> str:
181
+ """Return the colour of a point on the effort scale, from cheap to dear.
182
+
183
+ The ramp runs through the theme's own accents — green, a brighter green,
184
+ amber, and the error colour — rather than through fixed hues, so the
185
+ gauge follows `/theme` and a light palette gets the darker ends of the
186
+ same story. Position on the scale is the only input: the same level is
187
+ the same colour whether it is being switched to or away from.
188
+ """
189
+ theme = self.app.theme
190
+ return heat_colour(theme, heat)
191
+
192
+ def _status_left(self) -> TextLine:
193
+ """Return the status line's left side, whatever the plugins put there.
194
+
195
+ The keys on the right win a narrow row and the left is cut from its end, which is where ``ctx`` stands:
196
+ the figure that says how near the window is to full would be the first thing a narrow terminal lost. So
197
+ when the side does not fit, the counters that matter least are left out whole — the speed, then the
198
+ cache share, then the tokens — and the end of the side is only cut when nothing more can be spared.
199
+ """
200
+ line = self._side("status", "left")
201
+ room = self.app.width - self._side("status", "right").width - STATUS_GAP
202
+ spared: tuple[str, ...] = ()
203
+ for name in SPARED_FIRST:
204
+ if line.width <= room:
205
+ break
206
+ spared = (*spared, name)
207
+ line = self._side("status", "left", spare=spared)
208
+ return line
209
+
210
+ def _status_right(self) -> TextLine:
211
+ """Return the status line's right side, whatever the plugins put there."""
212
+ return self._side("status", "right")
213
+
214
+ def _side(self, region: UiRegion, side: UiSide, spare: tuple[str, ...] = ()) -> TextLine:
215
+ """Paint one side of one row from the segments the plugins registered.
216
+
217
+ The shell owns only this loop: the builtin rows are a plugin like any
218
+ other, and a plugin that overrode one of its slots already sits in its
219
+ place. A segment that returns nothing is skipped, one that raises is
220
+ dropped for that frame only, and one that returns ``(line, True)`` takes
221
+ the side over — the segments painted before it are dropped. A segment may
222
+ also return a :class:`~zettcode.plugin_api.rows.Row`, which is written out without the parts the
223
+ reader switched off in ``/statusline``. ``spare`` names parts to leave out as well, for a row that
224
+ has not the room for them.
225
+ """
226
+ row = next((row for row in self._plugin_rows if row.region == region), None)
227
+ segments = () if row is None else (row.left if side == "left" else row.right)
228
+ style = Style(foreground=self.app.theme.muted)
229
+ hidden = (*self.agent.runtime.config.statusline_hidden, *spare)
230
+ spans: list[Span] = []
231
+ context: ShellContext | None = None
232
+ for segment in segments:
233
+ if context is None:
234
+ context = self._shell_context()
235
+ try:
236
+ value = segment.builder(context)
237
+ except Exception:
238
+ continue
239
+ overrides = False
240
+ if isinstance(value, tuple):
241
+ value, overrides = value
242
+ if isinstance(value, Row):
243
+ value = value.text(hidden)
244
+ line = value if isinstance(value, TextLine) else TextLine((Span(str(value)),)) if value else None
245
+ if overrides:
246
+ spans.clear()
247
+ if line is None or not line.width:
248
+ continue
249
+ if spans:
250
+ spans.append(Span(f" {SEPARATOR} ", style))
251
+ spans.extend(line.spans)
252
+ return TextLine(tuple(spans))
253
+
254
+ def _shell_context(self) -> ShellContext:
255
+ """Snapshot what a plugin's segment builder reads, rebuilt for one paint."""
256
+ model = self.model
257
+ return ShellContext(
258
+ config=self.agent.runtime.config,
259
+ session=SessionState(
260
+ id=self.client.session.id,
261
+ title=self._session_title,
262
+ name=self._session_title or UNTITLED_SESSION,
263
+ workspace=self.client.workspace,
264
+ handle=self.messenger.handle if self.messenger is not None else "",
265
+ ),
266
+ model=ModelState(
267
+ config=model,
268
+ name="" if model is None else model.name,
269
+ effort=self.client.effort,
270
+ efforts=self.client.efforts,
271
+ ),
272
+ activity=ActivityState(
273
+ busy=self._busy,
274
+ status=self._activity,
275
+ note=self._note,
276
+ auto_shell=self.client.permission == "full",
277
+ permission=self.client.permission,
278
+ self_compact=self.client.self_compaction,
279
+ usage=self._usage,
280
+ tasks=plan_rows(self.client.session.tasks()),
281
+ frame=self.transcript.frame,
282
+ ),
283
+ display=DisplayState(
284
+ theme=self.app.theme,
285
+ width=self.app.width,
286
+ height=self.app.height,
287
+ screen=self.app.screens.top.name,
288
+ scrolled_up=self.view.scrolled_up,
289
+ ),
290
+ )
@@ -0,0 +1,131 @@
1
+ """The first frame: the composer, in the rows the shell will fill in.
2
+
3
+ The application's own modules cost about 130 ms to import and build — a config
4
+ read, the runtime preview, the plugins, the widget library — and none of it is
5
+ needed to put an input box on screen. This module draws that box from the
6
+ framework alone, so the reader's first keystrokes have somewhere to land, and
7
+ the application replaces the tree as soon as it is built
8
+ (:meth:`~zettcode.tui.TuiApp.set_root`).
9
+
10
+ The rows are the shell's own, in its order and its sizes, and the ones this
11
+ frame has nothing to say yet are left blank rather than filled with a guess:
12
+ ``VBox`` reserves them either way, so the swap fills them in without moving a
13
+ single row. The composer is the framework's :class:`~zettcode.tui.TextArea`,
14
+ which the real composer extends, configured the way the shell configures it —
15
+ so the box that is already on screen is the box that stays there.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Collection
21
+ from pathlib import Path
22
+
23
+ from zettcode.plugin_api.entries import EntryKind, TextEntry
24
+ from zettcode.plugin_api.presenters import CONTENT_INDENT
25
+ from zettcode.presenters import labels
26
+ from zettcode.presenters.presenters import WelcomePresenter
27
+ from zettcode.tui import PROMPT, Canvas, Constraints, Rule, Size, StatusBar, Text, TextArea, TextLine, VBox, Widget
28
+ from zettcode.tui.layout import Slot
29
+
30
+ from .widgets.welcome import WELCOME
31
+
32
+ #: What the composer shows before anything is typed, matching the real one.
33
+ PLACEHOLDER = "Ask ZettCode to do anything"
34
+
35
+ #: Rows the real composer grows to before it scrolls, matching the shell's.
36
+ COMPOSER_ROWS = 8
37
+
38
+ #: The status word and session name the shell shows before anything runs. The
39
+ #: shell's own text, so the row it draws does not change when it arrives; the
40
+ #: test that compares the two frames cell by cell is what keeps them together.
41
+ IDLE_STATUS = "ready"
42
+ UNTITLED = "New session"
43
+
44
+
45
+ def boot_tree(
46
+ workspace: Path,
47
+ *,
48
+ resuming: bool = False,
49
+ model: str | None = None,
50
+ effort: str = labels.DEFAULT_EFFORT,
51
+ hidden: Collection[str] = (),
52
+ ) -> VBox:
53
+ """Return the frame drawn while the application is still loading.
54
+
55
+ From the top: the header, its rule, the transcript, the blank row the shell
56
+ keeps above the composer, the composer, and the status line. The header
57
+ knows the workspace from the command line, the model from the config file,
58
+ and the composer needs nothing else, so those rows are the shell's own text;
59
+ the transcript and the token counters arrive with the application.
60
+
61
+ Args:
62
+ workspace: Directory the session runs in, as the header will show it.
63
+ resuming: A stored session is about to be loaded, so the transcript will
64
+ hold a conversation rather than a banner; drawing the banner would
65
+ only be taken away again a moment later.
66
+ model: Name to show at the right of the header, read from the config by
67
+ the caller — this frame draws before the settings are loaded. Without
68
+ one the corner says ``no model``, the same row a model-less machine
69
+ draws, rather than an empty segment the shell has to fill in.
70
+ effort: Reasoning effort to show beside it; dropped by
71
+ :func:`labels.header_right` when there is no model to reason with.
72
+ hidden: Names of the header and status-line parts the config file switches off, so the frame is the
73
+ rows the shell will draw and not a row with parts it is about to take away.
74
+ """
75
+ composer = TextArea(
76
+ prompt=f"{PROMPT} ",
77
+ placeholder=PLACEHOLDER,
78
+ max_height=COMPOSER_ROWS,
79
+ surface=True,
80
+ )
81
+ return VBox(
82
+ [
83
+ Slot(
84
+ StatusBar(
85
+ labels.header_left(workspace, UNTITLED).text(hidden),
86
+ # Drawn even with no model: the first frame says `no model` rather than leaving the segment
87
+ # empty until the swap fills it in, and `header_right` drops the effort by itself when the
88
+ # model is not there.
89
+ labels.header_right(model or "", effort).text(hidden),
90
+ ),
91
+ size=1,
92
+ ),
93
+ Slot(Rule(), size=1),
94
+ Slot(Text("") if resuming else Welcome(), flex=1),
95
+ Slot(Text(""), size=1),
96
+ Slot(composer, size=lambda width: composer.preferred_height(width)),
97
+ Slot(
98
+ StatusBar(labels.status_left(IDLE_STATUS).text(hidden), labels.status_right().text(hidden)),
99
+ size=1,
100
+ ),
101
+ ]
102
+ )
103
+
104
+
105
+ class Welcome(Widget):
106
+ """The boot banner, drawn by the presenter the transcript will use.
107
+
108
+ The mark, its colours, the title's weight, and the hint's colour are the
109
+ transcript's decisions, so they are asked for here rather than rebuilt: a
110
+ second rendering would differ in some cell and that difference is what the
111
+ reader sees as the banner flashing when the application replaces this tree.
112
+ """
113
+
114
+ def _lines(self, width: int) -> list[TextLine]:
115
+ """Return the rows the banner paints at this width."""
116
+ entry = TextEntry(id=0, kind=EntryKind.WELCOME, text=WELCOME)
117
+ return WelcomePresenter().lines(entry, max(1, width - CONTENT_INDENT), self.theme, 0)
118
+
119
+ def measure(self, constraints: Constraints) -> Size:
120
+ """Ask for the rows the banner needs at the width it was offered."""
121
+ width = constraints.max_width if constraints.max_width is not None else 1
122
+ lines = self._lines(max(1, width))
123
+ return constraints.constrain(Size(max((line.width for line in lines), default=0), len(lines)))
124
+
125
+ def render(self, canvas: Canvas) -> None:
126
+ """Paint the banner's rows into the assigned rectangle."""
127
+ if self.rect.empty:
128
+ return
129
+ width = max(1, self.rect.width - CONTENT_INDENT)
130
+ for offset, line in enumerate(self._lines(self.rect.width)[: self.rect.height]):
131
+ canvas.draw_spans(self.rect.x + CONTENT_INDENT, self.rect.y + offset, line.spans, max_width=width)