zettcode-commands 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/commands/__init__.py +1 -0
- zettcode/commands/context.py +95 -0
- zettcode/commands/context_charts.py +283 -0
- zettcode/commands/context_page.py +418 -0
- zettcode/commands/handlers.py +646 -0
- zettcode/commands/hub.py +1077 -0
- zettcode/commands/hub_graph.py +736 -0
- zettcode/commands/plugin.py +56 -0
- zettcode/commands/py.typed +0 -0
- zettcode/commands/usage.py +46 -0
- zettcode/commands/usage_page.py +290 -0
- zettcode_commands-0.4.2.dist-info/METADATA +23 -0
- zettcode_commands-0.4.2.dist-info/RECORD +15 -0
- zettcode_commands-0.4.2.dist-info/WHEEL +4 -0
- zettcode_commands-0.4.2.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""The page ``/context`` opens: how full the window is, and where the tokens in it went."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import monotonic
|
|
9
|
+
|
|
10
|
+
from zettcode.app.ui.widgets.effort_page import heat_colour
|
|
11
|
+
from zettcode.presenters.labels import compact_path
|
|
12
|
+
from zettcode.sdk.types import ContextReport
|
|
13
|
+
from zettcode.tui import SEPARATOR, KeyEvent, MouseAction, MouseEvent, Style, Widget
|
|
14
|
+
from zettcode.tui.render import blend, display_width
|
|
15
|
+
|
|
16
|
+
from . import context_charts as charts
|
|
17
|
+
|
|
18
|
+
#: Rows above the body: a blank, the title, and the file the session is stored in.
|
|
19
|
+
HEAD_ROWS = 3
|
|
20
|
+
|
|
21
|
+
#: How far a mouse-wheel notch moves the body.
|
|
22
|
+
WHEEL_ROWS = 3
|
|
23
|
+
|
|
24
|
+
#: How far ← and → move the share the window is compacted at: five percent a press, to a multiple of five.
|
|
25
|
+
PERCENT_STEP = 5
|
|
26
|
+
PERCENT_LEAST = 1
|
|
27
|
+
|
|
28
|
+
#: Seconds the knob takes to travel to the share it was sent to; the same as the other panels' knobs.
|
|
29
|
+
SLIDE_SECONDS = 0.42
|
|
30
|
+
|
|
31
|
+
#: The columns the label before the rail takes, the most the rail is drawn across, and the least.
|
|
32
|
+
LABEL_WIDTH = 13
|
|
33
|
+
RAIL_MAX = 72
|
|
34
|
+
RAIL_MIN = 10
|
|
35
|
+
|
|
36
|
+
#: Columns kept beside the number for what the share was, when the rail has room to spare for it.
|
|
37
|
+
NOTE_WIDTH = 10
|
|
38
|
+
|
|
39
|
+
#: Characters of the rail: the lit stretch, the groove ahead, the knob, and the mark where it began.
|
|
40
|
+
LIT, GROOVE, KNOB, ORIGIN = "\u2501", "\u2500", "\u25cf", "\u2506"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ContextPage(Widget):
|
|
44
|
+
"""A full-screen page that shows what the next request is made of.
|
|
45
|
+
|
|
46
|
+
Shape::
|
|
47
|
+
|
|
48
|
+
Context Opus 5 <- title and model
|
|
49
|
+
~/.zettcode/sessions/…/conversation.jsonl <- where this session is stored
|
|
50
|
+
Window 17,926 / 200,000 tokens 9.0% full
|
|
51
|
+
██▌░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
|
52
|
+
▲ compacts at 160,000 tokens (80.0%)
|
|
53
|
+
Used 17,926 9.0%
|
|
54
|
+
Until compaction 142,074 71.0%
|
|
55
|
+
Free in window 182,074 91.0%
|
|
56
|
+
|
|
57
|
+
Where it goes how the 17,926 tokens divide between sources
|
|
58
|
+
██████████████████████████▓▓▓▓▓▒▒▒░ <- the sources, side by side
|
|
59
|
+
Source Tokens Share Items
|
|
60
|
+
■ System prompt 2,700 15.1% 1
|
|
61
|
+
…
|
|
62
|
+
esc back
|
|
63
|
+
|
|
64
|
+
The gauge answers how full the window is and warms as the request nears the point where it
|
|
65
|
+
is compacted, so a reader sees the trouble coming rather than the number. The bar and the rows
|
|
66
|
+
under it answer a different question, where the tokens went, and divide the request rather than
|
|
67
|
+
the window: a source is a share of the request and not of the model. The two ratios are never
|
|
68
|
+
drawn on one scale. The total is the provider's whenever it reported one; the local count only
|
|
69
|
+
supplies the proportions, so the rows add up to it exactly.
|
|
70
|
+
|
|
71
|
+
The page paints on every frame from the lines the chart module describes, so a resize costs
|
|
72
|
+
nothing but a repaint, and a terminal too short for all of it scrolls.
|
|
73
|
+
|
|
74
|
+
When it is given the share the window is compacted at, a row above the keys shows it as a rail with
|
|
75
|
+
a knob, and ← → slide the knob by five percent (a click on the rail sends it there):
|
|
76
|
+
the rail runs through the theme's colours from calm to hot, the knob is a single dot that slides along it,
|
|
77
|
+
and the mark under the gauge glides with it, from the clock and not from a frame count. Nothing is
|
|
78
|
+
written until Enter, and Escape leaves the model as it was.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
report: ContextReport,
|
|
84
|
+
*,
|
|
85
|
+
path: Path | None = None,
|
|
86
|
+
model: str = "",
|
|
87
|
+
trigger: int | None = None,
|
|
88
|
+
compact_percent: float | None = None,
|
|
89
|
+
on_apply: Callable[[float], None] | None = None,
|
|
90
|
+
on_cancel: Callable[[], None] | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Hold the report to draw.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
report: Breakdown to show, as produced by the context extension.
|
|
96
|
+
path: File this session is stored in. The home directory collapses to ``~``; a line
|
|
97
|
+
too long for the page is cut at the edge, so a narrow terminal loses the end of
|
|
98
|
+
the path and not which file it named.
|
|
99
|
+
model: The model the window belongs to, shown beside the title.
|
|
100
|
+
trigger: Tokens at which a request is compacted; the gauge marks it. ``None`` for a
|
|
101
|
+
model that is never compacted.
|
|
102
|
+
compact_percent: The share of the window at which a request is compacted, to be moved with ← →.
|
|
103
|
+
``None`` leaves the page a read-only report, and the mark is drawn at ``trigger``.
|
|
104
|
+
on_apply: Receives the share when Enter is pressed on one that was moved; it owns closing the page.
|
|
105
|
+
on_cancel: Called on Escape instead of the page popping itself, for an owner that
|
|
106
|
+
keeps its own screen stack.
|
|
107
|
+
"""
|
|
108
|
+
super().__init__()
|
|
109
|
+
self.report = report
|
|
110
|
+
self.path = compact_path(path, limit=None) if path is not None else ""
|
|
111
|
+
self.model = model
|
|
112
|
+
self.trigger = trigger
|
|
113
|
+
self.start_percent = compact_percent
|
|
114
|
+
self.percent = compact_percent
|
|
115
|
+
#: Where the knob set off from, when it set off, and whether it is still on its way.
|
|
116
|
+
self._origin = compact_percent
|
|
117
|
+
self._set_off = 0.0
|
|
118
|
+
self._moving = False
|
|
119
|
+
self._token = f"context-panel:{id(self)}"
|
|
120
|
+
#: Where the rail was drawn last (left column, row, width), for a click to land on.
|
|
121
|
+
self._rail: tuple[int, int, int] | None = None
|
|
122
|
+
self.on_apply = on_apply
|
|
123
|
+
self.on_cancel = on_cancel
|
|
124
|
+
self.offset = 0
|
|
125
|
+
self._body_rows = 0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def focusable(self) -> bool:
|
|
129
|
+
"""Take the keyboard when the page opens, so its keys work without a click."""
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def title(self) -> str:
|
|
134
|
+
"""Return the heading: the page's name and how much of the window the request fills."""
|
|
135
|
+
return f"Context {self.report.used:,} / {self.report.window:,} tokens"
|
|
136
|
+
|
|
137
|
+
def lines(self, width: int) -> list[charts.Line]:
|
|
138
|
+
"""Return the body at ``width`` columns."""
|
|
139
|
+
return charts.context_lines(self.report, self._trigger(), width)
|
|
140
|
+
|
|
141
|
+
def _trigger(self) -> int | None:
|
|
142
|
+
"""Return the tokens a request is compacted at: where the knob is now, else the point given."""
|
|
143
|
+
if self.percent is None or not self.report.window:
|
|
144
|
+
return self.trigger
|
|
145
|
+
return max(1, round(self.report.window * self.knob_at(self._now()) / 100))
|
|
146
|
+
|
|
147
|
+
def _now(self) -> float:
|
|
148
|
+
return self.app.clock() if self.app is not None else monotonic()
|
|
149
|
+
|
|
150
|
+
def knob_at(self, now: float) -> float:
|
|
151
|
+
"""Return the share the knob is at, which is between where it set off and where it is going while it travels."""
|
|
152
|
+
assert self.percent is not None
|
|
153
|
+
if not self._moving or self._origin is None:
|
|
154
|
+
return self.percent
|
|
155
|
+
progress = (now - self._set_off) / SLIDE_SECONDS
|
|
156
|
+
if progress >= 1.0:
|
|
157
|
+
return self.percent
|
|
158
|
+
eased = 1 - (1 - max(0.0, progress)) ** 3
|
|
159
|
+
return self._origin + (self.percent - self._origin) * eased
|
|
160
|
+
|
|
161
|
+
def _slide_to(self, share: float) -> None:
|
|
162
|
+
"""Send the knob to a share, from wherever it is now; with reduced motion it is simply there."""
|
|
163
|
+
assert self.percent is not None
|
|
164
|
+
share = float(min(100, max(PERCENT_LEAST, share)))
|
|
165
|
+
if share == self.percent:
|
|
166
|
+
return
|
|
167
|
+
now = self._now()
|
|
168
|
+
self._origin = self.knob_at(now)
|
|
169
|
+
self.percent = share
|
|
170
|
+
self._set_off = now
|
|
171
|
+
if self.app is not None and self.app.reduced_motion:
|
|
172
|
+
self._moving = False
|
|
173
|
+
self._origin = share
|
|
174
|
+
return
|
|
175
|
+
self._moving = True
|
|
176
|
+
if self.app is not None:
|
|
177
|
+
self.app.scheduler.animate(self._token)
|
|
178
|
+
|
|
179
|
+
def on_tick(self) -> bool:
|
|
180
|
+
"""Hand the frame loop back once the knob has arrived; say whether the picture is still changing."""
|
|
181
|
+
if not self._moving:
|
|
182
|
+
return False
|
|
183
|
+
if self._now() - self._set_off >= SLIDE_SECONDS:
|
|
184
|
+
self._moving = False
|
|
185
|
+
self._origin = self.percent
|
|
186
|
+
if self.app is not None:
|
|
187
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def on_unmount(self) -> None:
|
|
191
|
+
"""Release the frame loop, so a page put away mid-slide stops costing frames."""
|
|
192
|
+
self._moving = False
|
|
193
|
+
if self.app is not None:
|
|
194
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def adjustable(self) -> bool:
|
|
198
|
+
"""Return whether the share can be moved here."""
|
|
199
|
+
return self.percent is not None and self.on_apply is not None
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def changed(self) -> bool:
|
|
203
|
+
"""Return whether the share is anywhere but where it started."""
|
|
204
|
+
return self.percent != self.start_percent
|
|
205
|
+
|
|
206
|
+
def _move_percent(self, direction: int) -> None:
|
|
207
|
+
"""Move the share to the next multiple of five in ``direction``, and never off 1 to 100."""
|
|
208
|
+
assert self.percent is not None
|
|
209
|
+
if direction > 0:
|
|
210
|
+
moved = math.floor(self.percent / PERCENT_STEP + 1) * PERCENT_STEP
|
|
211
|
+
else:
|
|
212
|
+
moved = (math.ceil(self.percent / PERCENT_STEP) - 1) * PERCENT_STEP
|
|
213
|
+
self._slide_to(moved)
|
|
214
|
+
|
|
215
|
+
# -- input --------------------------------------------------------------
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
"""Leave the page: through the owner's callback when it gave one, else by popping itself."""
|
|
218
|
+
if self.on_cancel is not None:
|
|
219
|
+
self.on_cancel()
|
|
220
|
+
elif self.app is not None and self.app.screens.top.widget is self:
|
|
221
|
+
self.app.pop_screen()
|
|
222
|
+
self.app.request_layout()
|
|
223
|
+
|
|
224
|
+
def capture_event(self, event, host) -> bool:
|
|
225
|
+
"""Close on Escape before anything else can take the key."""
|
|
226
|
+
if isinstance(event, KeyEvent) and event.key == "escape":
|
|
227
|
+
self.close()
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
def handle(self, event, host) -> bool:
|
|
232
|
+
"""Scroll the body, or close on ``q``."""
|
|
233
|
+
if isinstance(event, MouseEvent):
|
|
234
|
+
if event.action is MouseAction.SCROLL_UP:
|
|
235
|
+
self._scroll(-WHEEL_ROWS)
|
|
236
|
+
elif event.action is MouseAction.SCROLL_DOWN:
|
|
237
|
+
self._scroll(WHEEL_ROWS)
|
|
238
|
+
elif event.action is MouseAction.DOWN and self._rail is not None and self.adjustable:
|
|
239
|
+
left, row, width = self._rail
|
|
240
|
+
if event.y != row or not left <= event.x < left + width:
|
|
241
|
+
return False
|
|
242
|
+
self._slide_to(round((event.x - left) / max(1, width - 1) * 100))
|
|
243
|
+
else:
|
|
244
|
+
return False
|
|
245
|
+
host.invalidate()
|
|
246
|
+
return True
|
|
247
|
+
key = self._key_of(event)
|
|
248
|
+
if self.adjustable and key in ("left", "right", "enter"):
|
|
249
|
+
if key == "enter":
|
|
250
|
+
if self.changed:
|
|
251
|
+
assert self.percent is not None and self.on_apply is not None
|
|
252
|
+
self.on_apply(self.percent)
|
|
253
|
+
else:
|
|
254
|
+
self._move_percent(-1 if key == "left" else 1)
|
|
255
|
+
host.invalidate()
|
|
256
|
+
return True
|
|
257
|
+
if key in ("up", "k"):
|
|
258
|
+
self._scroll(-1)
|
|
259
|
+
elif key in ("down", "j"):
|
|
260
|
+
self._scroll(1)
|
|
261
|
+
elif key == "page_up":
|
|
262
|
+
self._scroll(-max(1, self._body_rows - 1))
|
|
263
|
+
elif key == "page_down":
|
|
264
|
+
self._scroll(max(1, self._body_rows - 1))
|
|
265
|
+
elif key == "home":
|
|
266
|
+
self.offset = 0
|
|
267
|
+
elif key == "end":
|
|
268
|
+
self._scroll(10**9)
|
|
269
|
+
elif key == "q":
|
|
270
|
+
self.close()
|
|
271
|
+
else:
|
|
272
|
+
return False
|
|
273
|
+
host.invalidate()
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _key_of(event: object) -> str | None:
|
|
278
|
+
"""Return the key an event names, whether it came as a key or as typed text."""
|
|
279
|
+
if isinstance(event, KeyEvent):
|
|
280
|
+
return None if event.alt or event.control else event.key
|
|
281
|
+
text = getattr(event, "text", None)
|
|
282
|
+
return text if isinstance(text, str) and len(text) == 1 else None
|
|
283
|
+
|
|
284
|
+
def _scroll(self, rows: int) -> None:
|
|
285
|
+
"""Move the body, never past its first row or its last."""
|
|
286
|
+
furthest = max(0, len(self.lines(max(1, self.rect.width - 4))) - self._body_rows)
|
|
287
|
+
self.offset = min(max(0, self.offset + rows), furthest)
|
|
288
|
+
|
|
289
|
+
# -- painting -----------------------------------------------------------
|
|
290
|
+
def style_of(self, role: str) -> Style:
|
|
291
|
+
"""Return how a role is painted in the active theme.
|
|
292
|
+
|
|
293
|
+
Each source has a colour of its own, taken from the hues the theme already gives the tool
|
|
294
|
+
families, so every palette has seven that are told apart without this page asking for any.
|
|
295
|
+
"""
|
|
296
|
+
theme = self.theme
|
|
297
|
+
page = theme.surface_alt
|
|
298
|
+
tools = theme.tools
|
|
299
|
+
colours = {
|
|
300
|
+
"text": theme.text,
|
|
301
|
+
"heading": theme.text,
|
|
302
|
+
"muted": theme.muted,
|
|
303
|
+
"track": blend(page, theme.text, 0.18),
|
|
304
|
+
"gauge_calm": theme.accent,
|
|
305
|
+
"gauge_warm": theme.warning,
|
|
306
|
+
"gauge_hot": theme.error,
|
|
307
|
+
"source0": tools.read,
|
|
308
|
+
"source1": tools.subagent,
|
|
309
|
+
"source2": tools.message,
|
|
310
|
+
"source3": theme.accent,
|
|
311
|
+
"source4": tools.write,
|
|
312
|
+
"source5": theme.warning,
|
|
313
|
+
"source6": theme.subtle,
|
|
314
|
+
}
|
|
315
|
+
return Style(foreground=colours.get(role, theme.text), background=page, bold=role == "heading")
|
|
316
|
+
|
|
317
|
+
def render(self, canvas) -> None:
|
|
318
|
+
"""Fill the page, then draw the title, the file, the body, and the footer."""
|
|
319
|
+
if self.rect.empty:
|
|
320
|
+
return
|
|
321
|
+
theme = self.theme
|
|
322
|
+
rect = self.rect
|
|
323
|
+
canvas.fill(rect.x, rect.y, rect.width, rect.height, Style(background=theme.surface_alt))
|
|
324
|
+
room = max(0, rect.width - 4)
|
|
325
|
+
used = canvas.draw_text(rect.x + 2, rect.y + 1, "Context", self.style_of("heading"), max_width=room)
|
|
326
|
+
if self.model:
|
|
327
|
+
canvas.draw_text(
|
|
328
|
+
rect.x + 2 + used + 2, rect.y + 1, self.model, self.style_of("muted"), max_width=max(0, room - used - 2)
|
|
329
|
+
)
|
|
330
|
+
if self.path:
|
|
331
|
+
canvas.draw_text(rect.x + 2, rect.y + 2, self.path, self.style_of("muted"), max_width=room)
|
|
332
|
+
body = self.lines(room)
|
|
333
|
+
top = rect.y + HEAD_ROWS + 1
|
|
334
|
+
control = self.adjustable
|
|
335
|
+
self._body_rows = max(0, rect.bottom - 2 - top - (1 if control else 0))
|
|
336
|
+
self.offset = min(self.offset, max(0, len(body) - self._body_rows))
|
|
337
|
+
for row, line in enumerate(body[self.offset : self.offset + self._body_rows]):
|
|
338
|
+
self._draw(canvas, rect.x + 2, top + row, line, room)
|
|
339
|
+
keys = "\u2191\u2193 scroll" if len(body) > self._body_rows else ""
|
|
340
|
+
if control:
|
|
341
|
+
self._draw_control(canvas, rect.x + 2, rect.bottom - 2, room)
|
|
342
|
+
keys = f" {SEPARATOR} ".join(part for part in (keys, "\u2190 \u2192 compress at", "enter apply") if part)
|
|
343
|
+
hint = f" {SEPARATOR} ".join(part for part in (keys, "esc back") if part)
|
|
344
|
+
canvas.draw_text(rect.x + 2, rect.bottom - 1, hint, self.style_of("muted"), max_width=room)
|
|
345
|
+
|
|
346
|
+
def _draw_control(self, canvas, x: int, y: int, room: int) -> None:
|
|
347
|
+
"""Draw the share the window is compacted at as a rail with a knob, its value, and what it was until Enter.
|
|
348
|
+
|
|
349
|
+
The rail is the theme's own ramp, calm to hot, drawn lit up to the knob and dimmed beyond it, so
|
|
350
|
+
where the knob stands is told by colour as well as place. The knob is one dot on one cell, and nothing
|
|
351
|
+
follows it: the rail is as wide as the page allows, so five percent is several cells of travel.
|
|
352
|
+
"""
|
|
353
|
+
assert self.percent is not None and self.start_percent is not None
|
|
354
|
+
theme = self.theme
|
|
355
|
+
surface = theme.surface_alt
|
|
356
|
+
now = self._now()
|
|
357
|
+
shown = self.knob_at(now)
|
|
358
|
+
# By the clock, not by the flag: the frame after the knob lands is drawn before the tick that clears it.
|
|
359
|
+
travelling = self._moving and now - self._set_off < SLIDE_SECONDS
|
|
360
|
+
canvas.draw_text(x, y, "Compress at", self.style_of("heading"), max_width=room)
|
|
361
|
+
width = min(RAIL_MAX, room - LABEL_WIDTH - 6 - NOTE_WIDTH)
|
|
362
|
+
if width < RAIL_MIN:
|
|
363
|
+
width = min(RAIL_MAX, room - LABEL_WIDTH - 6)
|
|
364
|
+
value = self._shown(self.percent) if not travelling else f"{round(shown)}"
|
|
365
|
+
hue = heat_colour(theme, shown / 100)
|
|
366
|
+
if width < RAIL_MIN:
|
|
367
|
+
self._rail = None
|
|
368
|
+
canvas.draw_text(
|
|
369
|
+
x + LABEL_WIDTH,
|
|
370
|
+
y,
|
|
371
|
+
f"{value}%",
|
|
372
|
+
Style(foreground=hue, background=surface, bold=True),
|
|
373
|
+
max_width=max(0, room - LABEL_WIDTH),
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
left = x + LABEL_WIDTH
|
|
377
|
+
self._rail = (left, y, width)
|
|
378
|
+
last = width - 1
|
|
379
|
+
# One knob on one cell, and nothing trailing it: the rail is wide enough that five percent is several cells,
|
|
380
|
+
# and the colour of the knob and of the number follows the position exactly, so the glide reads as one move.
|
|
381
|
+
knob = round(shown / 100 * last)
|
|
382
|
+
glow = blend(hue, theme.text, 0.35)
|
|
383
|
+
for cell in range(width):
|
|
384
|
+
base = heat_colour(theme, cell / last)
|
|
385
|
+
if cell == knob:
|
|
386
|
+
canvas.draw_text(left + cell, y, KNOB, Style(foreground=glow, background=surface, bold=True))
|
|
387
|
+
continue
|
|
388
|
+
colour, glyph = (base, LIT) if cell < knob else (blend(base, surface, 0.5), GROOVE)
|
|
389
|
+
canvas.draw_text(left + cell, y, glyph, Style(foreground=colour, background=surface))
|
|
390
|
+
began = round(self.start_percent / 100 * last)
|
|
391
|
+
if self.changed and began != knob:
|
|
392
|
+
canvas.draw_text(left + began, y, ORIGIN, Style(foreground=theme.muted, background=surface, bold=True))
|
|
393
|
+
after = left + width + 1
|
|
394
|
+
number = f"{value}%"
|
|
395
|
+
canvas.draw_text(after, y, number, Style(foreground=hue, background=surface, bold=True), max_width=5)
|
|
396
|
+
beyond = after + max(4, display_width(number)) + 2
|
|
397
|
+
note = f"was {self._shown(self.start_percent)}%" if self.changed else ""
|
|
398
|
+
if note and x + room - beyond >= display_width(note):
|
|
399
|
+
canvas.draw_text(
|
|
400
|
+
beyond,
|
|
401
|
+
y,
|
|
402
|
+
note,
|
|
403
|
+
Style(foreground=theme.accent if self.changed else theme.muted, background=surface),
|
|
404
|
+
max_width=x + room - beyond,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _shown(percent: float) -> str:
|
|
409
|
+
"""Return a share without a pointless decimal: ``80`` for 80.0, ``82.5`` as it is."""
|
|
410
|
+
return f"{percent:g}"
|
|
411
|
+
|
|
412
|
+
def _draw(self, canvas, x: int, y: int, line: charts.Line, room: int) -> None:
|
|
413
|
+
"""Draw one line of roles from ``x``, stopping at the edge of the page."""
|
|
414
|
+
used = 0
|
|
415
|
+
for text, role in line:
|
|
416
|
+
if used >= room:
|
|
417
|
+
break
|
|
418
|
+
used += canvas.draw_text(x + used, y, text, self.style_of(role), max_width=room - used)
|