plexi-sdk 0.4.0__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.
- plexi_sdk/__init__.py +477 -0
- plexi_sdk/_app.py +1077 -0
- plexi_sdk/_constants.py +52 -0
- plexi_sdk/_emitter.py +1466 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +976 -0
- plexi_sdk/_types.py +139 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/app_init.py +72 -0
- plexi_sdk/testing.py +451 -0
- plexi_sdk/ui.py +1535 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +60 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +159 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +218 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.4.0.dist-info/METADATA +127 -0
- plexi_sdk-0.4.0.dist-info/RECORD +25 -0
- plexi_sdk-0.4.0.dist-info/WHEEL +4 -0
plexi_sdk/ui.py
ADDED
|
@@ -0,0 +1,1535 @@
|
|
|
1
|
+
"""Plexi SDK v2 — declarative UI primitives.
|
|
2
|
+
|
|
3
|
+
A component tree that lays itself out and emits low-level `DrawCommand`s.
|
|
4
|
+
Apps describe *what* the screen should look like; the SDK handles *where*.
|
|
5
|
+
|
|
6
|
+
Design goals:
|
|
7
|
+
- Hard to make ugly UI. Defaults do the right thing.
|
|
8
|
+
- Compose: a `Card` can hold `KeyRow`s, a `Column` can hold `Card`s.
|
|
9
|
+
- Responsive: components truncate, wrap, or scroll instead of clipping.
|
|
10
|
+
- Escape hatch: apps that need pixel control still have `ctx.rect` /
|
|
11
|
+
`ctx.text` from the lower-level API.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from plexi_sdk import App, RenderContext
|
|
15
|
+
from plexi_sdk.ui import Column, Header, Card, KeyRow, Section, Spacer, Footer
|
|
16
|
+
|
|
17
|
+
def on_render(self, ctx: RenderContext) -> None:
|
|
18
|
+
ctx.render(Column([
|
|
19
|
+
Header("My App", "Short subtitle"),
|
|
20
|
+
Card([
|
|
21
|
+
KeyRow("m", "Message"),
|
|
22
|
+
KeyRow("c", "Choice"),
|
|
23
|
+
]),
|
|
24
|
+
Section("Events"),
|
|
25
|
+
Spacer(grow=True),
|
|
26
|
+
Footer("Status line"),
|
|
27
|
+
]))
|
|
28
|
+
|
|
29
|
+
## Component measurement
|
|
30
|
+
|
|
31
|
+
Each component reports a `measure(avail_w) -> height` used in a single
|
|
32
|
+
top-to-bottom pass. `Spacer(grow=True)` reports 0 and is expanded in a
|
|
33
|
+
second pass to consume whatever slack is left. When the pane is smaller
|
|
34
|
+
than the total fixed-height content, grow spacers collapse to 0 and
|
|
35
|
+
content at the bottom may not render — keep the total intentionally
|
|
36
|
+
below the minimum pane size, or use `ScrollLog` for variable content.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import List, Optional, Union
|
|
41
|
+
|
|
42
|
+
# ── Style tokens ──────────────────────────────────────────────────────────
|
|
43
|
+
# Keep these in sync with Rust's src/style.rs. Adding a token here without
|
|
44
|
+
# a matching Rust constant is fine (pure Python), but overlap should match.
|
|
45
|
+
|
|
46
|
+
# Spacing (pixels). 4-based scale.
|
|
47
|
+
SPACE_XS = 4.0
|
|
48
|
+
SPACE_SM = 8.0
|
|
49
|
+
SPACE_MD = 12.0
|
|
50
|
+
SPACE_LG = 16.0
|
|
51
|
+
SPACE_XL = 24.0
|
|
52
|
+
|
|
53
|
+
# Typography (pt).
|
|
54
|
+
TEXT_HINT = 11.0
|
|
55
|
+
TEXT_CAPTION = 12.0
|
|
56
|
+
TEXT_BODY = 14.0
|
|
57
|
+
TEXT_HEADING = 16.0
|
|
58
|
+
TEXT_TITLE = 20.0
|
|
59
|
+
TEXT_TITLE_XL = 28.0
|
|
60
|
+
|
|
61
|
+
# Radii.
|
|
62
|
+
RADIUS_SM = 4.0
|
|
63
|
+
RADIUS_MD = 8.0
|
|
64
|
+
RADIUS_LG = 12.0
|
|
65
|
+
# Badge-specific radius — between tag-chip (4) and full-stadium (8). At
|
|
66
|
+
# TEXT_HINT size the pill height is ~17 px; RADIUS_MD makes it 94% of
|
|
67
|
+
# max-oval (cliché). 6.0 gives visible corners while staying clearly rounded.
|
|
68
|
+
# Keep in sync with src/style.rs RADIUS_BADGE and _render_context.py badge().
|
|
69
|
+
RADIUS_BADGE = 6.0
|
|
70
|
+
|
|
71
|
+
# Palette — matches the Python-side constants from plexi_sdk/__init__.py.
|
|
72
|
+
# Re-exported here so UI code doesn't have to import both.
|
|
73
|
+
BG = "#1e1e2e"
|
|
74
|
+
SURFACE = "#313244"
|
|
75
|
+
HIGHLIGHT = "#45475a"
|
|
76
|
+
ACCENT = "#89b4fa"
|
|
77
|
+
MUTED = "#6c7086"
|
|
78
|
+
FG = "#cdd6f4"
|
|
79
|
+
RED = "#f38ba8"
|
|
80
|
+
GREEN = "#a6e3a1"
|
|
81
|
+
YELLOW = "#f9e2af"
|
|
82
|
+
|
|
83
|
+
# ── Utilities ──────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _wrap_to_width(text: str, avail_px: float, font_size: float,
|
|
87
|
+
mono: bool = False, max_lines: int = 3) -> List[str]:
|
|
88
|
+
"""Word-wrap `text` into up to `max_lines` lines. Final line gets an
|
|
89
|
+
ellipsis if content was truncated.
|
|
90
|
+
|
|
91
|
+
Uses approximate character-width ratios (0.60 mono, 0.55 proportional)
|
|
92
|
+
for layout arithmetic only. Actual clip/elision at render time is handled
|
|
93
|
+
by the host via `max_width` on `ctx.text()`.
|
|
94
|
+
"""
|
|
95
|
+
if avail_px <= 0 or not text:
|
|
96
|
+
return []
|
|
97
|
+
char_w = font_size * (0.60 if mono else 0.55)
|
|
98
|
+
max_chars = max(1, int(avail_px / char_w))
|
|
99
|
+
|
|
100
|
+
words = text.split()
|
|
101
|
+
lines: List[str] = []
|
|
102
|
+
current = ""
|
|
103
|
+
for word in words:
|
|
104
|
+
candidate = word if not current else f"{current} {word}"
|
|
105
|
+
if len(candidate) <= max_chars:
|
|
106
|
+
current = candidate
|
|
107
|
+
continue
|
|
108
|
+
if current:
|
|
109
|
+
lines.append(current)
|
|
110
|
+
current = word
|
|
111
|
+
else:
|
|
112
|
+
# Single word longer than line — hard-break.
|
|
113
|
+
lines.append(word[: max_chars - 1] + "…")
|
|
114
|
+
current = ""
|
|
115
|
+
if len(lines) >= max_lines:
|
|
116
|
+
break
|
|
117
|
+
if current and len(lines) < max_lines:
|
|
118
|
+
lines.append(current)
|
|
119
|
+
|
|
120
|
+
if len(lines) == max_lines and (
|
|
121
|
+
sum(len(l) for l in lines) + len(lines) - 1 < len(text)
|
|
122
|
+
):
|
|
123
|
+
last = lines[-1]
|
|
124
|
+
if not last.endswith("…"):
|
|
125
|
+
if len(last) >= max_chars:
|
|
126
|
+
last = last[: max_chars - 1] + "…"
|
|
127
|
+
else:
|
|
128
|
+
last = last + "…"
|
|
129
|
+
lines[-1] = last
|
|
130
|
+
return lines
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _markdown_measure_lines(text: str, avail_px: float, font_size: float,
|
|
134
|
+
max_lines: int) -> int:
|
|
135
|
+
"""Conservative line estimate for host-rendered markdown blocks.
|
|
136
|
+
|
|
137
|
+
The real markdown renderer lives in Rust/egui, so the Python SDK cannot
|
|
138
|
+
know exact glyph metrics or list indentation. This estimator intentionally
|
|
139
|
+
errs tall: chat bubbles should have a little breathing room, not clip the
|
|
140
|
+
final markdown row outside the bubble.
|
|
141
|
+
"""
|
|
142
|
+
if avail_px <= 0 or not text:
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
char_w = font_size * 0.55
|
|
146
|
+
base_chars = max(1, int(avail_px / char_w))
|
|
147
|
+
total = 0
|
|
148
|
+
|
|
149
|
+
for raw in text.splitlines() or [text]:
|
|
150
|
+
line = raw.strip()
|
|
151
|
+
if not line:
|
|
152
|
+
total += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# CommonMark lists/code blocks reserve horizontal space for markers
|
|
156
|
+
# and indentation; reduce the wrapping budget to match that shape.
|
|
157
|
+
budget = base_chars
|
|
158
|
+
if line.startswith(("- ", "* ", "+ ")) or line[:3].isdigit():
|
|
159
|
+
budget = max(1, budget - 4)
|
|
160
|
+
line = line[2:].strip() if not line[:3].isdigit() else line
|
|
161
|
+
elif line.startswith(("```", " ")):
|
|
162
|
+
budget = max(1, int(budget * 0.82))
|
|
163
|
+
|
|
164
|
+
# Markdown headings/lists get extra vertical rhythm in egui_commonmark.
|
|
165
|
+
block_extra = 1 if line.startswith(("#", "- ", "* ", "+ ", "```")) else 0
|
|
166
|
+
wrapped = _wrap_to_width(line, avail_px=budget * char_w,
|
|
167
|
+
font_size=font_size, max_lines=max_lines)
|
|
168
|
+
total += max(1, len(wrapped)) + block_extra
|
|
169
|
+
if total >= max_lines:
|
|
170
|
+
return max_lines
|
|
171
|
+
|
|
172
|
+
return min(total, max_lines)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── Component base ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Component:
|
|
179
|
+
"""Base class. Subclasses implement `measure` and `render`."""
|
|
180
|
+
|
|
181
|
+
def measure(self, _avail_w: float) -> float:
|
|
182
|
+
"""Return pixel height this component needs within `avail_w`."""
|
|
183
|
+
return 0.0
|
|
184
|
+
|
|
185
|
+
def is_grow(self) -> bool:
|
|
186
|
+
"""True if the component grows to fill remaining space."""
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def render(self, _ctx, _x: float, _y: float, _w: float, _h: float) -> None:
|
|
190
|
+
"""Emit draw commands. Implementations should stay within (x, y, w, h)."""
|
|
191
|
+
raise NotImplementedError
|
|
192
|
+
|
|
193
|
+
def _render_clipped(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
194
|
+
"""Render this component clipped to its allocated rect.
|
|
195
|
+
|
|
196
|
+
Container components (Column, Card) call this instead of `render` when
|
|
197
|
+
descending into children so each child's draws are bounded to its rect.
|
|
198
|
+
The PushClip/PopClip pair is emitted unconditionally; the host intersects
|
|
199
|
+
the new rect with the current clip stack top (only ever tightens).
|
|
200
|
+
"""
|
|
201
|
+
ctx.push_clip(x, y, w, h)
|
|
202
|
+
try:
|
|
203
|
+
self.render(ctx, x, y, w, h)
|
|
204
|
+
finally:
|
|
205
|
+
ctx.pop_clip()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ── Leaf components ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class Heading(Component):
|
|
213
|
+
"""Title-ish text. level 1 = TEXT_TITLE_XL, 2 = TEXT_TITLE, 3 = TEXT_HEADING.
|
|
214
|
+
|
|
215
|
+
`ctx.text(x, y, ...)` treats `y` as the TOP of the text box (host renders
|
|
216
|
+
with egui::Align2::LEFT_TOP). A Heading with font size `fs` occupies rows
|
|
217
|
+
`y` .. `y + fs` plus descender padding.
|
|
218
|
+
"""
|
|
219
|
+
text: str
|
|
220
|
+
level: int = 1
|
|
221
|
+
color: str = FG
|
|
222
|
+
bold: bool = True
|
|
223
|
+
|
|
224
|
+
DESCENDER_PAD = 3.0
|
|
225
|
+
|
|
226
|
+
def _font_size(self) -> float:
|
|
227
|
+
return {
|
|
228
|
+
1: TEXT_TITLE_XL,
|
|
229
|
+
2: TEXT_TITLE,
|
|
230
|
+
3: TEXT_HEADING,
|
|
231
|
+
}.get(self.level, TEXT_TITLE)
|
|
232
|
+
|
|
233
|
+
def measure(self, _avail_w: float) -> float:
|
|
234
|
+
return self._font_size() + self.DESCENDER_PAD
|
|
235
|
+
|
|
236
|
+
def render(self, ctx, x: float, y: float, w: float, _h: float) -> None:
|
|
237
|
+
fs = self._font_size()
|
|
238
|
+
ctx.text(x, y, self.text, size=fs, color=self.color, bold=self.bold,
|
|
239
|
+
max_width=w, elide=True)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class Label(Component):
|
|
244
|
+
"""Body/caption/hint text. Wraps up to `max_lines`, then truncates.
|
|
245
|
+
|
|
246
|
+
Line height = font_size + `LINE_LEADING`; lines stack top-to-bottom with
|
|
247
|
+
the first line's top at the component's `y`.
|
|
248
|
+
"""
|
|
249
|
+
text: str
|
|
250
|
+
tone: str = "body" # "body" | "caption" | "hint"
|
|
251
|
+
color: Optional[str] = None
|
|
252
|
+
bold: bool = False
|
|
253
|
+
max_lines: int = 3
|
|
254
|
+
|
|
255
|
+
LINE_LEADING = 4.0
|
|
256
|
+
# Extra space below the last line's baseline to avoid clipping
|
|
257
|
+
# descenders (g, y, p, q). Roughly 20% of body font size.
|
|
258
|
+
DESCENDER_PAD = 3.0
|
|
259
|
+
|
|
260
|
+
def _font_size(self) -> float:
|
|
261
|
+
return {
|
|
262
|
+
"body": TEXT_BODY,
|
|
263
|
+
"caption": TEXT_CAPTION,
|
|
264
|
+
"hint": TEXT_HINT,
|
|
265
|
+
}.get(self.tone, TEXT_BODY)
|
|
266
|
+
|
|
267
|
+
def _color(self) -> str:
|
|
268
|
+
if self.color:
|
|
269
|
+
return self.color
|
|
270
|
+
return {
|
|
271
|
+
"body": FG,
|
|
272
|
+
"caption": FG,
|
|
273
|
+
"hint": MUTED,
|
|
274
|
+
}.get(self.tone, FG)
|
|
275
|
+
|
|
276
|
+
def _lines(self, avail_w: float) -> List[str]:
|
|
277
|
+
return _wrap_to_width(self.text, avail_w, self._font_size(),
|
|
278
|
+
max_lines=self.max_lines)
|
|
279
|
+
|
|
280
|
+
def _line_h(self) -> float:
|
|
281
|
+
return self._font_size() + self.LINE_LEADING
|
|
282
|
+
|
|
283
|
+
def measure(self, avail_w: float) -> float:
|
|
284
|
+
lines = self._lines(avail_w)
|
|
285
|
+
if not lines:
|
|
286
|
+
return 0.0
|
|
287
|
+
# n lines = n-1 leadings + n font heights; equivalently n*line_h - leading.
|
|
288
|
+
# Add descender padding so the last line's descenders aren't clipped.
|
|
289
|
+
return len(lines) * self._line_h() - self.LINE_LEADING + self.DESCENDER_PAD
|
|
290
|
+
|
|
291
|
+
def render(self, ctx, x: float, y: float, w: float, _h: float) -> None:
|
|
292
|
+
fs = self._font_size()
|
|
293
|
+
color = self._color()
|
|
294
|
+
line_h = self._line_h()
|
|
295
|
+
for i, line in enumerate(self._lines(w)):
|
|
296
|
+
ctx.text(x, y + i * line_h, line,
|
|
297
|
+
size=fs, color=color, bold=self.bold)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class Spacer(Component):
|
|
302
|
+
"""Fixed or flex gap. `grow=True` expands to consume remaining space."""
|
|
303
|
+
size: float = SPACE_MD
|
|
304
|
+
grow: bool = False
|
|
305
|
+
|
|
306
|
+
def is_grow(self) -> bool:
|
|
307
|
+
return self.grow
|
|
308
|
+
|
|
309
|
+
def measure(self, _avail_w: float) -> float:
|
|
310
|
+
return 0.0 if self.grow else self.size
|
|
311
|
+
|
|
312
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class Divider(Component):
|
|
318
|
+
"""A horizontal 1px rule."""
|
|
319
|
+
color: str = HIGHLIGHT
|
|
320
|
+
margin_top: float = SPACE_SM
|
|
321
|
+
margin_bottom: float = SPACE_SM
|
|
322
|
+
|
|
323
|
+
def measure(self, avail_w: float) -> float:
|
|
324
|
+
return 1.0 + self.margin_top + self.margin_bottom
|
|
325
|
+
|
|
326
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
327
|
+
ctx.rect(x, y + self.margin_top, w, 1.0, self.color)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass
|
|
331
|
+
class AppBar(Component):
|
|
332
|
+
"""Thin top-of-pane app bar with optional subtitle.
|
|
333
|
+
|
|
334
|
+
Single-line (title only): fixed BAND_H band, title vertically centred.
|
|
335
|
+
Two-line (title + subtitle): taller BAND_H_DOUBLE band, title/subtitle
|
|
336
|
+
stacked and centred together.
|
|
337
|
+
"""
|
|
338
|
+
title: str
|
|
339
|
+
subtitle: Optional[str] = None
|
|
340
|
+
accent: str = FG
|
|
341
|
+
|
|
342
|
+
TITLE_SIZE = 16.0
|
|
343
|
+
SUBTITLE_SIZE = TEXT_HINT
|
|
344
|
+
BAND_H = 36.0
|
|
345
|
+
BAND_H_DOUBLE = 52.0
|
|
346
|
+
DIVIDER_H = 1.0
|
|
347
|
+
|
|
348
|
+
def _band(self) -> float:
|
|
349
|
+
return self.BAND_H_DOUBLE if self.subtitle else self.BAND_H
|
|
350
|
+
|
|
351
|
+
def measure(self, avail_w: float) -> float:
|
|
352
|
+
return self._band() + self.DIVIDER_H
|
|
353
|
+
|
|
354
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
355
|
+
ctx.rect(x, y, w, h, BG)
|
|
356
|
+
band = self._band()
|
|
357
|
+
text_x = x + SPACE_MD
|
|
358
|
+
text_w = w - 2 * SPACE_MD
|
|
359
|
+
if self.subtitle:
|
|
360
|
+
block_h = self.TITLE_SIZE + SPACE_XS + self.SUBTITLE_SIZE
|
|
361
|
+
title_y = y + (band - block_h) / 2.0
|
|
362
|
+
sub_y = title_y + self.TITLE_SIZE + SPACE_XS
|
|
363
|
+
ctx.text(text_x, title_y, self.title,
|
|
364
|
+
size=self.TITLE_SIZE, color=self.accent, bold=True,
|
|
365
|
+
max_width=text_w, elide=True)
|
|
366
|
+
ctx.text(text_x, sub_y, self.subtitle,
|
|
367
|
+
size=self.SUBTITLE_SIZE, color=MUTED,
|
|
368
|
+
max_width=text_w, elide=True)
|
|
369
|
+
else:
|
|
370
|
+
text_y = y + (band - self.TITLE_SIZE) / 2.0
|
|
371
|
+
ctx.text(text_x, text_y, self.title,
|
|
372
|
+
size=self.TITLE_SIZE, color=self.accent, bold=True,
|
|
373
|
+
max_width=text_w, elide=True)
|
|
374
|
+
ctx.rect(x, y + band, w, self.DIVIDER_H, HIGHLIGHT)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclass
|
|
378
|
+
class Section(Component):
|
|
379
|
+
"""Section divider with a small uppercase label sitting above the rule.
|
|
380
|
+
|
|
381
|
+
Vertical stack: SPACE_SM padding, label (TEXT_HINT), SPACE_XS, divider,
|
|
382
|
+
SPACE_SM padding.
|
|
383
|
+
"""
|
|
384
|
+
title: str
|
|
385
|
+
|
|
386
|
+
def measure(self, avail_w: float) -> float:
|
|
387
|
+
return SPACE_SM + TEXT_HINT + SPACE_XS + 1.0 + SPACE_SM
|
|
388
|
+
|
|
389
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
390
|
+
label_y = y + SPACE_SM
|
|
391
|
+
ctx.text(x, label_y, self.title.upper(),
|
|
392
|
+
size=TEXT_HINT, color=MUTED, bold=True,
|
|
393
|
+
max_width=w, elide=True)
|
|
394
|
+
line_y = label_y + TEXT_HINT + SPACE_XS
|
|
395
|
+
ctx.rect(x, line_y, w, 1.0, HIGHLIGHT)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass
|
|
399
|
+
class KeyRow(Component):
|
|
400
|
+
"""A keycap chip (or a chord of chips) followed by a description, left-aligned.
|
|
401
|
+
|
|
402
|
+
Emits DrawCommand::KeyChipRow — the host measures each chip with real font
|
|
403
|
+
metrics and flows them left-to-right. No Python-side width math.
|
|
404
|
+
|
|
405
|
+
`key` accepts a single string (e.g. `"m"`) or a list (e.g. `["⌘", "K"]`).
|
|
406
|
+
"""
|
|
407
|
+
key: Union[str, List[str]]
|
|
408
|
+
description: str
|
|
409
|
+
|
|
410
|
+
HEIGHT = 28.0
|
|
411
|
+
CHIP_PAD_V = 1.0 # used for measure() height only
|
|
412
|
+
|
|
413
|
+
def _keys(self) -> List[str]:
|
|
414
|
+
if isinstance(self.key, list):
|
|
415
|
+
return self.key
|
|
416
|
+
return [self.key]
|
|
417
|
+
|
|
418
|
+
def measure(self, avail_w: float) -> float:
|
|
419
|
+
return self.HEIGHT
|
|
420
|
+
|
|
421
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
422
|
+
# Vertical offset so the chip row is centred within HEIGHT.
|
|
423
|
+
chip_h = TEXT_HINT + self.CHIP_PAD_V * 2
|
|
424
|
+
chip_y = y + (self.HEIGHT - chip_h) / 2.0
|
|
425
|
+
ctx.key_chip_row(x=x, y=chip_y, keys=self._keys(),
|
|
426
|
+
description=self.description, font_size=TEXT_HINT)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass
|
|
430
|
+
class ScrollLog(Component):
|
|
431
|
+
"""Bounded text log. Shows the most recent lines that fit in the available
|
|
432
|
+
space; older lines are hidden. Lines are rendered newest-at-top."""
|
|
433
|
+
lines: List[str]
|
|
434
|
+
line_size: float = TEXT_CAPTION
|
|
435
|
+
empty_text: str = "no events yet"
|
|
436
|
+
max_pixel_height: Optional[float] = None
|
|
437
|
+
_assigned_h: float = field(default=0.0, repr=False)
|
|
438
|
+
|
|
439
|
+
def is_grow(self) -> bool:
|
|
440
|
+
# ScrollLog takes what it's given — typically follows a Spacer(grow=True).
|
|
441
|
+
# Marking it grow=False means it won't expand past its content unless
|
|
442
|
+
# explicitly sized. See `flex=True` variant below if we ever want that.
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
def measure(self, avail_w: float) -> float:
|
|
446
|
+
if not self.lines:
|
|
447
|
+
return self.line_size + 6.0
|
|
448
|
+
line_h = self.line_size + 4.0
|
|
449
|
+
content_h = len(self.lines) * line_h
|
|
450
|
+
if self.max_pixel_height is not None:
|
|
451
|
+
return min(content_h, self.max_pixel_height)
|
|
452
|
+
return content_h
|
|
453
|
+
|
|
454
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
455
|
+
if not self.lines:
|
|
456
|
+
ctx.text(x, y, self.empty_text,
|
|
457
|
+
size=self.line_size, color=MUTED)
|
|
458
|
+
return
|
|
459
|
+
line_h = self.line_size + 4.0
|
|
460
|
+
visible = max(1, int(h / line_h))
|
|
461
|
+
recent = list(reversed(self.lines[-visible:]))
|
|
462
|
+
for i, line in enumerate(recent):
|
|
463
|
+
ctx.text(x, y + i * line_h, line,
|
|
464
|
+
size=self.line_size, color=FG, monospace=True,
|
|
465
|
+
max_width=w, elide=True)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@dataclass
|
|
469
|
+
class Scrollable(Component):
|
|
470
|
+
"""A clip-bounded vertically-scrollable container.
|
|
471
|
+
|
|
472
|
+
Renders its `child` component clipped to the allocated rect. If the child
|
|
473
|
+
is taller than the available height the excess is hidden and a thin
|
|
474
|
+
scrollbar indicator is drawn on the right edge.
|
|
475
|
+
|
|
476
|
+
Scroll offset is persisted on the instance, so the `Scrollable` must be
|
|
477
|
+
stable across renders — create it once in `on_init` (or as a class
|
|
478
|
+
attribute), not inside `on_render`.
|
|
479
|
+
|
|
480
|
+
Keyboard scroll: j/k or arrow-down/up keys update `scroll_offset`.
|
|
481
|
+
Apps drive this by calling `handle_key(key)` from their `on_key` handler.
|
|
482
|
+
|
|
483
|
+
# IMPL-NOTE: Wheel/touch event plumbing is deferred. The PGAP protocol
|
|
484
|
+
# doesn't yet carry scroll-wheel events from the host to the app. Once
|
|
485
|
+
# PlexiEvent::Scroll is wired through (a follow-up to #314), Scrollable
|
|
486
|
+
# can subscribe to it here with no breaking changes. For now, keyboard
|
|
487
|
+
# scroll (j/k) is the v1 input source. Apps that need wheel scroll today
|
|
488
|
+
# should use the host-managed DrawCommand::List primitive instead.
|
|
489
|
+
"""
|
|
490
|
+
child: Component
|
|
491
|
+
scroll_offset: float = field(default=0.0, repr=False)
|
|
492
|
+
# How many pixels j/k advances per keypress.
|
|
493
|
+
key_step: float = 20.0
|
|
494
|
+
|
|
495
|
+
def __post_init__(self):
|
|
496
|
+
if not isinstance(self.child, Component):
|
|
497
|
+
raise TypeError(
|
|
498
|
+
f"Scrollable child must subclass Component, got {type(self.child).__name__}. "
|
|
499
|
+
"Ad-hoc widget classes missing _render_clipped will crash at render time."
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Width of the scrollbar indicator drawn when content overflows.
|
|
503
|
+
_SCROLLBAR_W: float = field(default=3.0, init=False, repr=False)
|
|
504
|
+
# Stored child height from last measure (used for scrollbar sizing).
|
|
505
|
+
_child_h: float = field(default=0.0, repr=False)
|
|
506
|
+
# Stored allocated height from last render (used for scroll clamping).
|
|
507
|
+
_avail_h: float = field(default=0.0, repr=False)
|
|
508
|
+
|
|
509
|
+
def measure(self, avail_w: float) -> float:
|
|
510
|
+
"""Scrollable reports 0 so it grows to consume available space."""
|
|
511
|
+
return 0.0
|
|
512
|
+
|
|
513
|
+
def is_grow(self) -> bool:
|
|
514
|
+
return True
|
|
515
|
+
|
|
516
|
+
def _clamp_offset(self, avail_h: float) -> None:
|
|
517
|
+
max_offset = max(0.0, self._child_h - avail_h)
|
|
518
|
+
self.scroll_offset = max(0.0, min(self.scroll_offset, max_offset))
|
|
519
|
+
|
|
520
|
+
def handle_key(self, key: str) -> bool:
|
|
521
|
+
"""Update scroll_offset for j/k/ArrowDown/ArrowUp keys.
|
|
522
|
+
|
|
523
|
+
Returns True if the key was consumed. Call from the app's on_key handler:
|
|
524
|
+
|
|
525
|
+
if self._scrollable.handle_key(key):
|
|
526
|
+
return # consumed
|
|
527
|
+
"""
|
|
528
|
+
if key in ("j", "ArrowDown", "down"):
|
|
529
|
+
self.scroll_offset += self.key_step
|
|
530
|
+
self._clamp_offset(self._avail_h)
|
|
531
|
+
return True
|
|
532
|
+
if key in ("k", "ArrowUp", "up"):
|
|
533
|
+
self.scroll_offset = max(0.0, self.scroll_offset - self.key_step)
|
|
534
|
+
return True
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
def ensure_visible(self, top: float, bottom: float, margin: float = 0.0) -> None:
|
|
538
|
+
"""See `ensure_visible(...)` free function. Wrapper for Scrollable's
|
|
539
|
+
own offset + cached viewport height. Use this from inside an app
|
|
540
|
+
that's wrapped its content in a Scrollable instance:
|
|
541
|
+
|
|
542
|
+
self._sel_idx = min(self._sel_idx + 1, len(items) - 1)
|
|
543
|
+
self._scrollable.ensure_visible(self._sel_idx * ROW_H,
|
|
544
|
+
self._sel_idx * ROW_H + ROW_H)
|
|
545
|
+
"""
|
|
546
|
+
if self._avail_h <= 0:
|
|
547
|
+
return
|
|
548
|
+
self.scroll_offset = ensure_visible(
|
|
549
|
+
self.scroll_offset, self._avail_h, top, bottom, margin=margin
|
|
550
|
+
)
|
|
551
|
+
self._clamp_offset(self._avail_h)
|
|
552
|
+
|
|
553
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
554
|
+
self._avail_h = h
|
|
555
|
+
# Measure child at our width (less scrollbar gutter).
|
|
556
|
+
content_w = w - self._SCROLLBAR_W - 2.0
|
|
557
|
+
self._child_h = self.child.measure(content_w)
|
|
558
|
+
self._clamp_offset(h)
|
|
559
|
+
|
|
560
|
+
# Clip to our allocated rect, then render child offset upward.
|
|
561
|
+
ctx.push_clip(x, y, w, h)
|
|
562
|
+
try:
|
|
563
|
+
child_y = y - self.scroll_offset
|
|
564
|
+
self.child.render(ctx, x, child_y, content_w, self._child_h)
|
|
565
|
+
finally:
|
|
566
|
+
ctx.pop_clip()
|
|
567
|
+
|
|
568
|
+
# Scrollbar indicator (only when content overflows).
|
|
569
|
+
if self._child_h > h and h > 0:
|
|
570
|
+
track_h = h
|
|
571
|
+
thumb_ratio = h / self._child_h
|
|
572
|
+
thumb_h = max(16.0, track_h * thumb_ratio)
|
|
573
|
+
thumb_y = y + (self.scroll_offset / self._child_h) * track_h
|
|
574
|
+
# Clamp thumb to track
|
|
575
|
+
thumb_y = min(thumb_y, y + track_h - thumb_h)
|
|
576
|
+
bar_x = x + w - self._SCROLLBAR_W
|
|
577
|
+
ctx.rect(bar_x, y, self._SCROLLBAR_W, track_h, HIGHLIGHT)
|
|
578
|
+
ctx.rect(bar_x, thumb_y, self._SCROLLBAR_W, thumb_h, MUTED)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def ensure_visible(scroll_offset: float, viewport_h: float,
|
|
582
|
+
top: float, bottom: float, margin: float = 0.0) -> float:
|
|
583
|
+
"""Solve 'selection follows scroll' in one call. Returns the new offset.
|
|
584
|
+
|
|
585
|
+
The pattern: the user is navigating items with j/k. The cursor moves
|
|
586
|
+
freely while it stays inside the visible viewport; the moment it would
|
|
587
|
+
go off the top or bottom edge, the viewport scrolls just enough to
|
|
588
|
+
keep it visible. Identical to every native list widget.
|
|
589
|
+
|
|
590
|
+
Apps call this from their nav handler after mutating their
|
|
591
|
+
selected_index — works whether the app uses a `Scrollable` component
|
|
592
|
+
or hand-rolls its own scroll offset (commit-graph's clip-and-offset
|
|
593
|
+
style):
|
|
594
|
+
|
|
595
|
+
new_sel = min(self._sel + 1, len(items) - 1)
|
|
596
|
+
item_top = new_sel * ROW_H
|
|
597
|
+
self._scroll_offset = ensure_visible(
|
|
598
|
+
self._scroll_offset, viewport_h,
|
|
599
|
+
top=item_top, bottom=item_top + ROW_H,
|
|
600
|
+
)
|
|
601
|
+
self._sel = new_sel
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
scroll_offset: current scroll offset in the child's local space.
|
|
605
|
+
viewport_h: visible height of the viewport.
|
|
606
|
+
top, bottom: the item's top/bottom edges in the child's local space.
|
|
607
|
+
margin: scrolloff equivalent. Set to one row-height to keep
|
|
608
|
+
a row of breathing room above/below the cursor.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
The new scroll_offset that keeps `[top, bottom]` visible. Identical
|
|
612
|
+
to the input if the cursor was already in view.
|
|
613
|
+
"""
|
|
614
|
+
if viewport_h <= 0:
|
|
615
|
+
return scroll_offset
|
|
616
|
+
cursor_top = scroll_offset + margin
|
|
617
|
+
cursor_bottom = scroll_offset + viewport_h - margin
|
|
618
|
+
if top < cursor_top:
|
|
619
|
+
return max(0.0, top - margin)
|
|
620
|
+
if bottom > cursor_bottom:
|
|
621
|
+
return bottom - viewport_h + margin
|
|
622
|
+
return scroll_offset
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@dataclass
|
|
626
|
+
class Footer(Component):
|
|
627
|
+
"""Small caption row. Wraps instead of clipping. The parent `Column`
|
|
628
|
+
provides the outer bottom padding, so no extra padding is needed here."""
|
|
629
|
+
text: str
|
|
630
|
+
color: str = MUTED
|
|
631
|
+
max_lines: int = 2
|
|
632
|
+
|
|
633
|
+
TOP_GAP = SPACE_MD
|
|
634
|
+
LINE_H = TEXT_HINT + 5.0
|
|
635
|
+
|
|
636
|
+
def _lines(self, avail_w: float) -> List[str]:
|
|
637
|
+
return _wrap_to_width(self.text, avail_w, TEXT_HINT,
|
|
638
|
+
max_lines=self.max_lines)
|
|
639
|
+
|
|
640
|
+
def measure(self, avail_w: float) -> float:
|
|
641
|
+
lines = self._lines(avail_w)
|
|
642
|
+
count = max(1, len(lines))
|
|
643
|
+
return self.TOP_GAP + 1.0 + self.TOP_GAP + count * self.LINE_H
|
|
644
|
+
|
|
645
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
646
|
+
line_y = y + self.TOP_GAP
|
|
647
|
+
ctx.rect(x, line_y, w, 1.0, HIGHLIGHT)
|
|
648
|
+
text_y = line_y + 1.0 + self.TOP_GAP
|
|
649
|
+
for i, line in enumerate(self._lines(w)):
|
|
650
|
+
ctx.text(x, text_y + i * self.LINE_H, line,
|
|
651
|
+
size=TEXT_HINT, color=self.color)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@dataclass
|
|
655
|
+
class FooterKeys(Component):
|
|
656
|
+
"""Footer row that renders keyboard shortcuts as key chips + descriptions.
|
|
657
|
+
|
|
658
|
+
Each shortcut is a ``(key_or_keys, description)`` tuple — the same shape
|
|
659
|
+
as ``KeyRow``. Chips are rendered inline (horizontal flow) separated by a
|
|
660
|
+
small gap, identical in style to ``KeyRow`` but packed tightly so many
|
|
661
|
+
shortcuts fit on one line.
|
|
662
|
+
|
|
663
|
+
Example::
|
|
664
|
+
|
|
665
|
+
FooterKeys([
|
|
666
|
+
("j", "down"),
|
|
667
|
+
("k", "up"),
|
|
668
|
+
(["g", "G"], "ends"),
|
|
669
|
+
("?", "help"),
|
|
670
|
+
])
|
|
671
|
+
|
|
672
|
+
``key_or_keys`` may be a single string or a list of strings (chord).
|
|
673
|
+
Lists are joined with ``/`` as a single chip label so they stay compact
|
|
674
|
+
in the footer context.
|
|
675
|
+
"""
|
|
676
|
+
shortcuts: List[tuple] # list of (key_or_keys, description)
|
|
677
|
+
|
|
678
|
+
# TOP_GAP reduced from SPACE_MD (12px) to SPACE_SM (8px) — trimmer chrome.
|
|
679
|
+
TOP_GAP = SPACE_SM
|
|
680
|
+
CHIP_H = TEXT_HINT + 2.0 * 1.0 # TEXT_HINT + 2*CHIP_PAD_V
|
|
681
|
+
# Single-row height. The host wraps the row to multiple lines when
|
|
682
|
+
# `max_width` can't fit everything; very narrow panes may render past
|
|
683
|
+
# this measurement. Apps wanting exact bounded footers should put
|
|
684
|
+
# FooterKeys in a fixed-height region or constrain the shortcut count.
|
|
685
|
+
ROW_H = CHIP_H + 2.0 # reduced from +4.0 — tighter without cramping chips
|
|
686
|
+
|
|
687
|
+
def measure(self, avail_w: float) -> float:
|
|
688
|
+
return self.TOP_GAP + 1.0 + self.TOP_GAP + self.ROW_H
|
|
689
|
+
|
|
690
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
691
|
+
# Opaque BG backdrop so any content scrolled behind the footer doesn't
|
|
692
|
+
# bleed through the divider line.
|
|
693
|
+
ctx.rect(x, y, w, h, BG)
|
|
694
|
+
line_y = y + self.TOP_GAP
|
|
695
|
+
ctx.rect(x, line_y, w, 1.0, HIGHLIGHT)
|
|
696
|
+
|
|
697
|
+
chip_row_y = line_y + 1.0 + self.TOP_GAP
|
|
698
|
+
|
|
699
|
+
# Single host-measured shortcuts row — host owns ALL geometry:
|
|
700
|
+
# chip widths from real font metrics, inter-group flow, and
|
|
701
|
+
# multi-line wrap when `max_width` is exceeded. SDK does no
|
|
702
|
+
# width math, no truncation, no overlap. This is the whole
|
|
703
|
+
# point of the host-measured layout primitives (#312).
|
|
704
|
+
ctx.shortcuts(
|
|
705
|
+
x=x,
|
|
706
|
+
y=chip_row_y,
|
|
707
|
+
max_width=w,
|
|
708
|
+
pairs=list(self.shortcuts),
|
|
709
|
+
font_size=TEXT_HINT,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ── Composite list components ──────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@dataclass
|
|
717
|
+
class ListItem(Component):
|
|
718
|
+
"""Single or double-line list item with optional leading icon and trailing text.
|
|
719
|
+
|
|
720
|
+
Replaces the manual ``ctx.rect`` + y-offset pattern for list rows.
|
|
721
|
+
All vertical centering is handled internally — no ``align=`` juggling or
|
|
722
|
+
``h * 0.38`` / ``h * 0.72`` magic numbers needed.
|
|
723
|
+
|
|
724
|
+
Example::
|
|
725
|
+
|
|
726
|
+
ListItem(
|
|
727
|
+
title=cmd["name"],
|
|
728
|
+
subtitle=cmd.get("description"),
|
|
729
|
+
trailing="›",
|
|
730
|
+
selected=(i == self._sel),
|
|
731
|
+
)
|
|
732
|
+
"""
|
|
733
|
+
title: str
|
|
734
|
+
subtitle: Optional[str] = None
|
|
735
|
+
leading: Optional[str] = None # icon character or short label
|
|
736
|
+
trailing: Optional[str] = None # chevron, badge text
|
|
737
|
+
selected: bool = False
|
|
738
|
+
background: Optional[str] = None # default: SURFACE (or HIGHLIGHT when selected)
|
|
739
|
+
radius: float = RADIUS_MD
|
|
740
|
+
|
|
741
|
+
HEIGHT_SINGLE = 36.0
|
|
742
|
+
HEIGHT_DOUBLE = 48.0
|
|
743
|
+
_LEAD_SLOT = SPACE_XL # fixed slot width for leading icon
|
|
744
|
+
_TRAIL_SLOT = SPACE_LG # fixed slot width for trailing text
|
|
745
|
+
_PAD_H = SPACE_MD # inner horizontal padding
|
|
746
|
+
|
|
747
|
+
def _h(self) -> float:
|
|
748
|
+
return self.HEIGHT_DOUBLE if self.subtitle else self.HEIGHT_SINGLE
|
|
749
|
+
|
|
750
|
+
def measure(self, avail_w: float) -> float:
|
|
751
|
+
return self._h()
|
|
752
|
+
|
|
753
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
754
|
+
bg = HIGHLIGHT if self.selected else (self.background or SURFACE)
|
|
755
|
+
ctx.rect(x, y, w, h, bg, radius=self.radius)
|
|
756
|
+
|
|
757
|
+
inner_x = x + self._PAD_H
|
|
758
|
+
inner_w = w - self._PAD_H * 2
|
|
759
|
+
|
|
760
|
+
if self.leading:
|
|
761
|
+
ctx.text(inner_x, y + h / 2.0, self.leading,
|
|
762
|
+
size=TEXT_BODY, color=MUTED, align="left_center")
|
|
763
|
+
inner_x += self._LEAD_SLOT
|
|
764
|
+
inner_w -= self._LEAD_SLOT
|
|
765
|
+
|
|
766
|
+
if self.trailing:
|
|
767
|
+
ctx.text(x + w - self._PAD_H, y + h / 2.0, self.trailing,
|
|
768
|
+
size=TEXT_HINT, color=MUTED, align="right_center")
|
|
769
|
+
inner_w -= self._TRAIL_SLOT
|
|
770
|
+
|
|
771
|
+
title_color = ACCENT if self.selected else FG
|
|
772
|
+
if self.subtitle:
|
|
773
|
+
ctx.text(inner_x, y + h * 0.35, self.title,
|
|
774
|
+
size=TEXT_BODY, color=title_color, bold=True,
|
|
775
|
+
align="left_center", max_width=inner_w, elide=True)
|
|
776
|
+
ctx.text(inner_x, y + h * 0.70, self.subtitle,
|
|
777
|
+
size=TEXT_HINT, color=MUTED,
|
|
778
|
+
align="left_center", max_width=inner_w, elide=True)
|
|
779
|
+
else:
|
|
780
|
+
ctx.text(inner_x, y + h / 2.0, self.title,
|
|
781
|
+
size=TEXT_BODY, color=title_color, bold=True,
|
|
782
|
+
align="left_center", max_width=inner_w, elide=True)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@dataclass
|
|
786
|
+
class Row(Component):
|
|
787
|
+
"""Horizontal row: optional leading icon, main label, optional trailing text.
|
|
788
|
+
|
|
789
|
+
Vertically centres all items automatically. Use instead of paired
|
|
790
|
+
``ctx.text(x, y + h/2, ..., align="left_center")`` calls when building
|
|
791
|
+
info rows with an icon, label, and badge or chevron.
|
|
792
|
+
|
|
793
|
+
Example::
|
|
794
|
+
|
|
795
|
+
Row(label="Workspace", leading="⚡", trailing=f"{count}")
|
|
796
|
+
"""
|
|
797
|
+
label: str
|
|
798
|
+
leading: Optional[str] = None # icon / short text, left slot
|
|
799
|
+
trailing: Optional[str] = None # badge / chevron, right slot
|
|
800
|
+
font_size: float = TEXT_BODY
|
|
801
|
+
color: str = FG
|
|
802
|
+
leading_color: Optional[str] = None # default: MUTED
|
|
803
|
+
trailing_color: Optional[str] = None # default: MUTED
|
|
804
|
+
height: Optional[float] = None # default: font_size + SPACE_MD
|
|
805
|
+
bold: bool = False
|
|
806
|
+
|
|
807
|
+
_LEAD_SLOT = SPACE_XL
|
|
808
|
+
_TRAIL_SLOT = SPACE_XL
|
|
809
|
+
|
|
810
|
+
def _h(self) -> float:
|
|
811
|
+
return self.height if self.height is not None else self.font_size + SPACE_MD
|
|
812
|
+
|
|
813
|
+
def measure(self, avail_w: float) -> float:
|
|
814
|
+
return self._h()
|
|
815
|
+
|
|
816
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
817
|
+
yc = y + h / 2.0
|
|
818
|
+
inner_x = x
|
|
819
|
+
inner_w = w
|
|
820
|
+
|
|
821
|
+
if self.leading:
|
|
822
|
+
ctx.text(inner_x, yc, self.leading,
|
|
823
|
+
size=self.font_size,
|
|
824
|
+
color=self.leading_color or MUTED,
|
|
825
|
+
align="left_center")
|
|
826
|
+
inner_x += self._LEAD_SLOT
|
|
827
|
+
inner_w -= self._LEAD_SLOT
|
|
828
|
+
|
|
829
|
+
if self.trailing:
|
|
830
|
+
ctx.text(x + w, yc, self.trailing,
|
|
831
|
+
size=self.font_size,
|
|
832
|
+
color=self.trailing_color or MUTED,
|
|
833
|
+
align="right_center")
|
|
834
|
+
inner_w -= self._TRAIL_SLOT
|
|
835
|
+
|
|
836
|
+
ctx.text(inner_x, yc, self.label,
|
|
837
|
+
size=self.font_size, color=self.color, bold=self.bold,
|
|
838
|
+
align="left_center", max_width=inner_w, elide=True)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# ── Badge primitive ────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def badge(
|
|
845
|
+
ctx,
|
|
846
|
+
x: float,
|
|
847
|
+
y_center: float,
|
|
848
|
+
label: str,
|
|
849
|
+
fill: str = ACCENT,
|
|
850
|
+
fg: str = BG,
|
|
851
|
+
font_size: float = TEXT_HINT,
|
|
852
|
+
radius: float = RADIUS_BADGE,
|
|
853
|
+
) -> None:
|
|
854
|
+
"""Render a host-measured pill badge centred on ``y_center``.
|
|
855
|
+
|
|
856
|
+
The host measures the label with real egui font metrics, sizes the pill
|
|
857
|
+
(text_w + padding), and centres the text — no Python width math.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
ctx: A ``RenderContext`` instance.
|
|
861
|
+
x: Left edge of the badge.
|
|
862
|
+
y_center: Vertical centre of the badge (e.g. the commit-node ``cy``).
|
|
863
|
+
label: Text to display inside the pill.
|
|
864
|
+
fill: Pill background colour.
|
|
865
|
+
fg: Text colour (default ``BG`` — dark text on light pill).
|
|
866
|
+
font_size: Label pt size (default ``TEXT_HINT``).
|
|
867
|
+
radius: Corner radius. Use ``RADIUS_SM`` (4 px) for tag chips,
|
|
868
|
+
``RADIUS_BADGE`` (6 px, default) for rounded badges without
|
|
869
|
+
the perfect-stadium look of ``RADIUS_MD`` (8 px).
|
|
870
|
+
"""
|
|
871
|
+
ctx.badge(x=x, y_center=y_center, label=label,
|
|
872
|
+
fill=fill, fg=fg, font_size=font_size, radius=radius)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# ── Loading pill (suspense indicator) ──────────────────────────────────────
|
|
876
|
+
#
|
|
877
|
+
# A small chip that apps overlay on top of stale content while a refresh
|
|
878
|
+
# is in flight. The point: don't full-swap the pane to a spinner card on
|
|
879
|
+
# every refresh — keep the existing UI mounted, surface a localised
|
|
880
|
+
# loading indicator only over the region that's being refreshed.
|
|
881
|
+
#
|
|
882
|
+
# Pattern in the calling app:
|
|
883
|
+
# 1. Track `_fetching: bool` separately from `_mode`.
|
|
884
|
+
# 2. On first-ever fetch, show `_render_loading()` (full-pane spinner).
|
|
885
|
+
# 3. On every subsequent fetch, set `_fetching = True` and re-render —
|
|
886
|
+
# the existing _render_ready stays up; loading_pill renders on top.
|
|
887
|
+
# 4. When the fetch completes: set `_fetching = False`, update data,
|
|
888
|
+
# re-render. Pill disappears, content updates in place.
|
|
889
|
+
#
|
|
890
|
+
# This is the SDK-level equivalent of React Suspense with stale-while-
|
|
891
|
+
# revalidate: the boundary stays mounted with current content; the only
|
|
892
|
+
# visual signal is a small pill instead of a destructive remount.
|
|
893
|
+
|
|
894
|
+
import time as _ct_time # `time` collides with some example apps' imports
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def loading_pill(ctx, x: float, y: float, label: str = "Fetching…") -> float:
|
|
898
|
+
"""Render a small spinner+label pill at (x, y). Returns rendered width.
|
|
899
|
+
|
|
900
|
+
The pill uses host-measured `badge()` rendering (so widths are
|
|
901
|
+
correct), with a wall-clock-driven Braille spinner glyph that ticks
|
|
902
|
+
at 8 fps regardless of how often `loading_pill` is called.
|
|
903
|
+
|
|
904
|
+
Pattern: position this in the top-right of the region being
|
|
905
|
+
refreshed. While `_fetching` is true, render it on top of the stale
|
|
906
|
+
content. When the fetch completes, just stop calling it.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
ctx: RenderContext.
|
|
910
|
+
x, y: Top-left of the pill (NOT y-centre — easier to anchor).
|
|
911
|
+
label: Text shown after the spinner glyph.
|
|
912
|
+
"""
|
|
913
|
+
spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
914
|
+
idx = int(_ct_time.monotonic() * 8) % len(spinner)
|
|
915
|
+
text = f"{spinner[idx]} {label}"
|
|
916
|
+
# Use the host-measured badge; subtle styling (surface fill, muted fg).
|
|
917
|
+
# Pill is anchored top-left here; convert to y_center for badge().
|
|
918
|
+
ctx.badge(x=x, y_center=y + 9.0, label=text,
|
|
919
|
+
fill=HIGHLIGHT, fg=FG, font_size=TEXT_HINT,
|
|
920
|
+
radius=RADIUS_SM)
|
|
921
|
+
# Approx width — not measured here because we don't need it for
|
|
922
|
+
# placement (callers anchor by top-right of the parent region).
|
|
923
|
+
return len(text) * TEXT_HINT * 0.62 + 16.0
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# ── Container components ───────────────────────────────────────────────────
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@dataclass
|
|
930
|
+
class Card(Component):
|
|
931
|
+
"""Surface-colored container with inner padding. Stacks its children
|
|
932
|
+
vertically with a configurable gap. A 1px border in HIGHLIGHT separates
|
|
933
|
+
it from the pane background — essential when SURFACE and BG are close
|
|
934
|
+
in brightness."""
|
|
935
|
+
children: List[Component]
|
|
936
|
+
padding: float = SPACE_LG
|
|
937
|
+
gap: float = SPACE_XS
|
|
938
|
+
background: str = SURFACE
|
|
939
|
+
border: Optional[str] = HIGHLIGHT # set to None for a borderless card
|
|
940
|
+
radius: float = RADIUS_MD
|
|
941
|
+
|
|
942
|
+
def __post_init__(self):
|
|
943
|
+
self.children = list(self.children)
|
|
944
|
+
for child in self.children:
|
|
945
|
+
if not isinstance(child, Component):
|
|
946
|
+
raise TypeError(
|
|
947
|
+
f"Card children must subclass Component, got {type(child).__name__}. "
|
|
948
|
+
"Ad-hoc widget classes missing _render_clipped will crash at render time."
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def _inner_w(self, outer_w: float) -> float:
|
|
952
|
+
return outer_w - 2 * self.padding
|
|
953
|
+
|
|
954
|
+
def measure(self, avail_w: float) -> float:
|
|
955
|
+
inner_w = self._inner_w(avail_w)
|
|
956
|
+
if not self.children:
|
|
957
|
+
return 2 * self.padding
|
|
958
|
+
child_heights = [c.measure(inner_w) for c in self.children]
|
|
959
|
+
total = sum(child_heights) + self.gap * max(0, len(self.children) - 1)
|
|
960
|
+
return total + 2 * self.padding
|
|
961
|
+
|
|
962
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
963
|
+
ctx.rect(x, y, w, h, self.background, radius=self.radius)
|
|
964
|
+
if self.border:
|
|
965
|
+
# Top + bottom + left + right 1px strokes. Drawn as four thin
|
|
966
|
+
# rects because `ctx.rect` doesn't support a separate stroke.
|
|
967
|
+
ctx.rect(x, y, w, 1.0, self.border)
|
|
968
|
+
ctx.rect(x, y + h - 1.0, w, 1.0, self.border)
|
|
969
|
+
ctx.rect(x, y, 1.0, h, self.border)
|
|
970
|
+
ctx.rect(x + w - 1.0, y, 1.0, h, self.border)
|
|
971
|
+
inner_x = x + self.padding
|
|
972
|
+
inner_y = y + self.padding
|
|
973
|
+
inner_w = w - 2 * self.padding
|
|
974
|
+
cursor = inner_y
|
|
975
|
+
for i, child in enumerate(self.children):
|
|
976
|
+
ch = child.measure(inner_w)
|
|
977
|
+
child._render_clipped(ctx, inner_x, cursor, inner_w, ch)
|
|
978
|
+
cursor += ch
|
|
979
|
+
if i < len(self.children) - 1:
|
|
980
|
+
cursor += self.gap
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@dataclass
|
|
984
|
+
class TextInput(Component):
|
|
985
|
+
"""Layout-aware text input. Place inside a Column like any other child.
|
|
986
|
+
|
|
987
|
+
After ``ctx.render(column)``, read ``.submitted`` to get the text the user
|
|
988
|
+
submitted (pressed Enter), or ``None`` if nothing was submitted this frame.
|
|
989
|
+
|
|
990
|
+
Create once (in ``on_init``) and update ``placeholder`` as needed — the
|
|
991
|
+
instance is stable across renders so the host can track focus state.
|
|
992
|
+
|
|
993
|
+
When ``multiline=True``, Shift+Enter inserts a newline and Enter submits.
|
|
994
|
+
"""
|
|
995
|
+
id: str
|
|
996
|
+
placeholder: str = ""
|
|
997
|
+
height: float = 48.0
|
|
998
|
+
multiline: bool = False
|
|
999
|
+
|
|
1000
|
+
_submitted: Optional[str] = field(default=None, init=False, repr=False)
|
|
1001
|
+
|
|
1002
|
+
def measure(self, avail_w: float) -> float:
|
|
1003
|
+
return self.height
|
|
1004
|
+
|
|
1005
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1006
|
+
self._submitted = ctx.text_input(self.id, x=x, y=y, w=w,
|
|
1007
|
+
placeholder=self.placeholder, h=h,
|
|
1008
|
+
multiline=self.multiline)
|
|
1009
|
+
|
|
1010
|
+
@property
|
|
1011
|
+
def submitted(self) -> Optional[str]:
|
|
1012
|
+
"""Text submitted this frame (user pressed Enter), else None."""
|
|
1013
|
+
return self._submitted
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@dataclass
|
|
1017
|
+
class ChatBubble(Component):
|
|
1018
|
+
"""A chat message bubble with left/right alignment and colored background.
|
|
1019
|
+
|
|
1020
|
+
``align="right"`` for user messages (accent bg), ``"left"`` for
|
|
1021
|
+
assistant messages (surface bg). Error messages use ``role="error"``.
|
|
1022
|
+
"""
|
|
1023
|
+
text: str
|
|
1024
|
+
role: str = "assistant"
|
|
1025
|
+
max_lines: int = 50
|
|
1026
|
+
|
|
1027
|
+
LINE_LEADING = 5.0
|
|
1028
|
+
DESCENDER_PAD = 5.0
|
|
1029
|
+
BUBBLE_PAD = SPACE_MD
|
|
1030
|
+
BUBBLE_MAX_FRAC = 0.78
|
|
1031
|
+
BUBBLE_MIN_W = 38.0
|
|
1032
|
+
|
|
1033
|
+
def _font_size(self) -> float:
|
|
1034
|
+
return TEXT_BODY
|
|
1035
|
+
|
|
1036
|
+
def _bubble_colors(self) -> tuple:
|
|
1037
|
+
if self.role == "user":
|
|
1038
|
+
return (ACCENT, "#1e1e2e")
|
|
1039
|
+
if self.role == "error":
|
|
1040
|
+
return ("#45171e", RED)
|
|
1041
|
+
return (SURFACE, FG)
|
|
1042
|
+
|
|
1043
|
+
def _plain_text(self) -> str:
|
|
1044
|
+
text = self.text
|
|
1045
|
+
for marker in ("**", "__", "`"):
|
|
1046
|
+
text = text.replace(marker, "")
|
|
1047
|
+
return text
|
|
1048
|
+
|
|
1049
|
+
def _natural_text_w(self) -> float:
|
|
1050
|
+
char_w = self._font_size() * 0.55
|
|
1051
|
+
lines = self._plain_text().splitlines() or [self._plain_text()]
|
|
1052
|
+
longest = max((len(line.strip()) for line in lines), default=0)
|
|
1053
|
+
return longest * char_w
|
|
1054
|
+
|
|
1055
|
+
def _bubble_w(self, avail_w: float) -> float:
|
|
1056
|
+
max_w = max(self.BUBBLE_MIN_W, avail_w * self.BUBBLE_MAX_FRAC)
|
|
1057
|
+
natural_w = self._natural_text_w() + 2 * self.BUBBLE_PAD
|
|
1058
|
+
return min(avail_w, max(self.BUBBLE_MIN_W, min(max_w, natural_w)))
|
|
1059
|
+
|
|
1060
|
+
def _text_w(self, avail_w: float) -> float:
|
|
1061
|
+
return self._bubble_w(avail_w) - 2 * self.BUBBLE_PAD
|
|
1062
|
+
|
|
1063
|
+
def _lines(self, avail_w: float) -> List[str]:
|
|
1064
|
+
return _wrap_to_width(self.text, self._text_w(avail_w),
|
|
1065
|
+
self._font_size(), max_lines=self.max_lines)
|
|
1066
|
+
|
|
1067
|
+
def _line_h(self) -> float:
|
|
1068
|
+
return self._font_size() + self.LINE_LEADING
|
|
1069
|
+
|
|
1070
|
+
def measure(self, avail_w: float) -> float:
|
|
1071
|
+
text_w = self._text_w(avail_w)
|
|
1072
|
+
line_count = _markdown_measure_lines(
|
|
1073
|
+
self.text, text_w, self._font_size(), self.max_lines
|
|
1074
|
+
)
|
|
1075
|
+
if line_count <= 0:
|
|
1076
|
+
return 0.0
|
|
1077
|
+
text_h = line_count * self._line_h() - self.LINE_LEADING + self.DESCENDER_PAD
|
|
1078
|
+
# egui_commonmark adds small margins around paragraphs and list blocks.
|
|
1079
|
+
return text_h + 2 * self.BUBBLE_PAD + SPACE_XS
|
|
1080
|
+
|
|
1081
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1082
|
+
bg, fg = self._bubble_colors()
|
|
1083
|
+
bubble_w = self._bubble_w(w)
|
|
1084
|
+
bx = x + w - bubble_w if self.role == "user" else x
|
|
1085
|
+
ctx.rect(bx, y, bubble_w, h, fill=bg, radius=RADIUS_LG)
|
|
1086
|
+
fs = self._font_size()
|
|
1087
|
+
text_x = bx + self.BUBBLE_PAD
|
|
1088
|
+
text_y = y + self.BUBBLE_PAD
|
|
1089
|
+
text_w = bubble_w - 2 * self.BUBBLE_PAD
|
|
1090
|
+
ctx.markdown(text_x, text_y, text_w, self.text, base_size=fs, color=fg)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
class SelectList(Component):
|
|
1094
|
+
"""Keyboard-navigable scrollable list. Stateful — create in on_init, not on_render.
|
|
1095
|
+
|
|
1096
|
+
items: list of dicts with keys: name (str), description (str, optional),
|
|
1097
|
+
leading (str, optional), trailing (str, optional)
|
|
1098
|
+
selected_idx: currently highlighted row index
|
|
1099
|
+
|
|
1100
|
+
Call handle_key(key) from on_key. Call hit_index(click_y) from on_click.
|
|
1101
|
+
"""
|
|
1102
|
+
|
|
1103
|
+
def __init__(self, items: List[dict], selected_idx: int = 0) -> None:
|
|
1104
|
+
self.items = items
|
|
1105
|
+
self.selected_idx = selected_idx
|
|
1106
|
+
self._scroll_px: float = 0.0
|
|
1107
|
+
self._viewport_h: float = 0.0
|
|
1108
|
+
self._rendered_rects: List[tuple] = [] # (y_top, y_bot, idx) populated each render
|
|
1109
|
+
|
|
1110
|
+
def is_grow(self) -> bool:
|
|
1111
|
+
return True
|
|
1112
|
+
|
|
1113
|
+
def measure(self, avail_w: float) -> float:
|
|
1114
|
+
return 0.0 # is_grow — allocated by parent
|
|
1115
|
+
|
|
1116
|
+
def _item_h(self, i: int) -> float:
|
|
1117
|
+
item = self.items[i]
|
|
1118
|
+
return ListItem.HEIGHT_DOUBLE if item.get("description") else ListItem.HEIGHT_SINGLE
|
|
1119
|
+
|
|
1120
|
+
def _item_top(self, idx: int) -> float:
|
|
1121
|
+
"""Item's y position in content-local space (before scroll)."""
|
|
1122
|
+
y = 0.0
|
|
1123
|
+
for i in range(idx):
|
|
1124
|
+
y += self._item_h(i) + SPACE_XS
|
|
1125
|
+
return y
|
|
1126
|
+
|
|
1127
|
+
def _total_content_h(self) -> float:
|
|
1128
|
+
if not self.items:
|
|
1129
|
+
return 0.0
|
|
1130
|
+
return sum(self._item_h(i) for i in range(len(self.items))) + SPACE_XS * (len(self.items) - 1)
|
|
1131
|
+
|
|
1132
|
+
def _clamp_scroll(self) -> None:
|
|
1133
|
+
max_scroll = max(0.0, self._total_content_h() - self._viewport_h)
|
|
1134
|
+
self._scroll_px = max(0.0, min(self._scroll_px, max_scroll))
|
|
1135
|
+
|
|
1136
|
+
def handle_key(self, key: str) -> bool:
|
|
1137
|
+
"""Update selection and scroll for j/k/arrows. Returns True if consumed."""
|
|
1138
|
+
total = len(self.items)
|
|
1139
|
+
if total == 0:
|
|
1140
|
+
return False
|
|
1141
|
+
if key in ("j", "ArrowDown", "down"):
|
|
1142
|
+
self.selected_idx = min(self.selected_idx + 1, total - 1)
|
|
1143
|
+
elif key in ("k", "ArrowUp", "up"):
|
|
1144
|
+
self.selected_idx = max(self.selected_idx - 1, 0)
|
|
1145
|
+
else:
|
|
1146
|
+
return False
|
|
1147
|
+
# Scroll to keep selected item visible
|
|
1148
|
+
item_top = self._item_top(self.selected_idx)
|
|
1149
|
+
item_bot = item_top + self._item_h(self.selected_idx)
|
|
1150
|
+
self._scroll_px = ensure_visible(self._scroll_px, self._viewport_h, item_top, item_bot)
|
|
1151
|
+
self._clamp_scroll()
|
|
1152
|
+
return True
|
|
1153
|
+
|
|
1154
|
+
def hit_index(self, click_y: float) -> Optional[int]:
|
|
1155
|
+
"""Return item index at click_y (screen coords from last render), or None."""
|
|
1156
|
+
for (yt, yb, idx) in self._rendered_rects:
|
|
1157
|
+
if yt <= click_y < yb:
|
|
1158
|
+
return idx
|
|
1159
|
+
return None
|
|
1160
|
+
|
|
1161
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1162
|
+
self._viewport_h = h
|
|
1163
|
+
self._rendered_rects = []
|
|
1164
|
+
self._clamp_scroll()
|
|
1165
|
+
|
|
1166
|
+
if not self.items:
|
|
1167
|
+
ctx.text(x + w / 2, y + h / 2, "No items",
|
|
1168
|
+
size=TEXT_HINT, color=MUTED, align="center")
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
ctx.push_clip(x, y, w, h)
|
|
1172
|
+
try:
|
|
1173
|
+
cursor_y = y - self._scroll_px
|
|
1174
|
+
for i, item in enumerate(self.items):
|
|
1175
|
+
ih = self._item_h(i)
|
|
1176
|
+
yt = cursor_y
|
|
1177
|
+
yb = cursor_y + ih
|
|
1178
|
+
if yb > y and yt < y + h:
|
|
1179
|
+
self._rendered_rects.append((yt, yb, i))
|
|
1180
|
+
li = ListItem(
|
|
1181
|
+
title=item["name"],
|
|
1182
|
+
subtitle=item.get("description") or None,
|
|
1183
|
+
leading=item.get("leading"),
|
|
1184
|
+
trailing=item.get("trailing"),
|
|
1185
|
+
selected=(i == self.selected_idx),
|
|
1186
|
+
)
|
|
1187
|
+
li.render(ctx, x, cursor_y, w, ih)
|
|
1188
|
+
cursor_y += ih + SPACE_XS
|
|
1189
|
+
finally:
|
|
1190
|
+
ctx.pop_clip()
|
|
1191
|
+
|
|
1192
|
+
# Scrollbar indicator when content overflows
|
|
1193
|
+
total_h = self._total_content_h()
|
|
1194
|
+
if total_h > h and h > 0:
|
|
1195
|
+
sb_w = 3.0
|
|
1196
|
+
thumb_ratio = h / total_h
|
|
1197
|
+
thumb_h = max(16.0, h * thumb_ratio)
|
|
1198
|
+
thumb_y = y + (self._scroll_px / total_h) * h
|
|
1199
|
+
thumb_y = min(thumb_y, y + h - thumb_h)
|
|
1200
|
+
bar_x = x + w - sb_w
|
|
1201
|
+
ctx.rect(bar_x, y, sb_w, h, HIGHLIGHT)
|
|
1202
|
+
ctx.rect(bar_x, thumb_y, sb_w, thumb_h, MUTED)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
@dataclass
|
|
1206
|
+
class FormField(Component):
|
|
1207
|
+
"""Label + TextInput row. Create in on_init (stable across renders).
|
|
1208
|
+
|
|
1209
|
+
Read .submitted after ctx.render() — it contains the text entered
|
|
1210
|
+
by the user when they pressed Enter, or None if no submission this frame.
|
|
1211
|
+
"""
|
|
1212
|
+
id: str
|
|
1213
|
+
label: str
|
|
1214
|
+
placeholder: str = ""
|
|
1215
|
+
required: bool = False
|
|
1216
|
+
height: float = 48.0
|
|
1217
|
+
|
|
1218
|
+
LABEL_H: float = TEXT_HINT + SPACE_XS # 11 + 4 = 15px
|
|
1219
|
+
LABEL_GAP: float = SPACE_SM # 8px gap between label and input
|
|
1220
|
+
BOTTOM_PAD: float = SPACE_LG # 16px below input before next item
|
|
1221
|
+
|
|
1222
|
+
def __post_init__(self) -> None:
|
|
1223
|
+
self._input: TextInput = TextInput(self.id, placeholder=self.placeholder, height=self.height)
|
|
1224
|
+
|
|
1225
|
+
@property
|
|
1226
|
+
def submitted(self) -> Optional[str]:
|
|
1227
|
+
"""Text submitted this frame (Enter pressed), or None."""
|
|
1228
|
+
return self._input.submitted
|
|
1229
|
+
|
|
1230
|
+
def measure(self, avail_w: float) -> float:
|
|
1231
|
+
return self.LABEL_H + self.LABEL_GAP + self.height + self.BOTTOM_PAD
|
|
1232
|
+
|
|
1233
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1234
|
+
req_suffix = " *" if self.required else ""
|
|
1235
|
+
ctx.text(x, y, f"{self.label}{req_suffix}",
|
|
1236
|
+
size=TEXT_HINT, color=MUTED)
|
|
1237
|
+
input_y = y + self.LABEL_H + self.LABEL_GAP
|
|
1238
|
+
self._input.render(ctx, x, input_y, w, self.height)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
@dataclass
|
|
1242
|
+
class Column(Component):
|
|
1243
|
+
"""The root container. Stacks children vertically. Handles grow spacers:
|
|
1244
|
+
measures fixed-height children first, then distributes leftover space to
|
|
1245
|
+
any `Spacer(grow=True)` descendants at the top level.
|
|
1246
|
+
|
|
1247
|
+
Padding defaults to `SPACE_XL` (24px) on the sides and bottom, and
|
|
1248
|
+
`SPACE_SM` (8px) on the top. A top-of-pane `Header` carries its own
|
|
1249
|
+
visual weight via TEXT_TITLE_XL and its own bottom rhythm (gap +
|
|
1250
|
+
divider), so anything above a few px reads as "the title is dropped"
|
|
1251
|
+
rather than anchored. The other three sides stay at 24px where content
|
|
1252
|
+
*does* need breathing room.
|
|
1253
|
+
|
|
1254
|
+
Override either with `padding=` (all sides) or `padding_top=` (top only).
|
|
1255
|
+
"""
|
|
1256
|
+
children: List[Component]
|
|
1257
|
+
padding: float = SPACE_XL
|
|
1258
|
+
padding_top: Optional[float] = None
|
|
1259
|
+
gap: float = SPACE_MD
|
|
1260
|
+
|
|
1261
|
+
def __post_init__(self):
|
|
1262
|
+
self.children = list(self.children)
|
|
1263
|
+
for child in self.children:
|
|
1264
|
+
if not isinstance(child, Component):
|
|
1265
|
+
raise TypeError(
|
|
1266
|
+
f"Column children must subclass Component, got {type(child).__name__}. "
|
|
1267
|
+
"Ad-hoc widget classes missing _render_clipped will crash at render time."
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
@property
|
|
1271
|
+
def _pad_top(self) -> float:
|
|
1272
|
+
return self.padding_top if self.padding_top is not None else SPACE_SM
|
|
1273
|
+
|
|
1274
|
+
def measure(self, avail_w: float) -> float:
|
|
1275
|
+
inner_w = avail_w - 2 * self.padding
|
|
1276
|
+
total = 0.0
|
|
1277
|
+
for i, c in enumerate(self.children):
|
|
1278
|
+
total += c.measure(inner_w)
|
|
1279
|
+
if i < len(self.children) - 1:
|
|
1280
|
+
total += self.gap
|
|
1281
|
+
return total + self._pad_top + self.padding
|
|
1282
|
+
|
|
1283
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1284
|
+
inner_x = x + self.padding
|
|
1285
|
+
inner_y = y + self._pad_top
|
|
1286
|
+
inner_w = w - 2 * self.padding
|
|
1287
|
+
inner_h = h - self._pad_top - self.padding
|
|
1288
|
+
|
|
1289
|
+
heights = [c.measure(inner_w) for c in self.children]
|
|
1290
|
+
gap_total = self.gap * max(0, len(self.children) - 1)
|
|
1291
|
+
fixed_used = sum(heights) + gap_total
|
|
1292
|
+
|
|
1293
|
+
grow_indices = [i for i, c in enumerate(self.children) if c.is_grow()]
|
|
1294
|
+
slack = max(0.0, inner_h - fixed_used)
|
|
1295
|
+
if grow_indices and slack > 0:
|
|
1296
|
+
share = slack / len(grow_indices)
|
|
1297
|
+
for i in grow_indices:
|
|
1298
|
+
heights[i] += share
|
|
1299
|
+
|
|
1300
|
+
cursor = inner_y
|
|
1301
|
+
for i, child in enumerate(self.children):
|
|
1302
|
+
ch = heights[i]
|
|
1303
|
+
if cursor + ch > inner_y + inner_h:
|
|
1304
|
+
# Clamp to remaining space; prevents overdraw on a too-small pane.
|
|
1305
|
+
ch = max(0.0, inner_y + inner_h - cursor)
|
|
1306
|
+
if ch <= 0:
|
|
1307
|
+
break
|
|
1308
|
+
child._render_clipped(ctx, inner_x, cursor, inner_w, ch)
|
|
1309
|
+
cursor += ch
|
|
1310
|
+
if i < len(self.children) - 1:
|
|
1311
|
+
cursor += self.gap
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
# ── Public render entry point ──────────────────────────────────────────────
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def render_tree(ctx, root: Component, fill: str = BG) -> None:
|
|
1318
|
+
"""Clear the pane to `fill`, then render `root` into the full pane rect.
|
|
1319
|
+
|
|
1320
|
+
Apps normally call `ctx.render(root)` instead, which calls this.
|
|
1321
|
+
"""
|
|
1322
|
+
ctx.clear(fill)
|
|
1323
|
+
root.render(ctx, 0.0, 0.0, ctx.w, ctx.h)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
@dataclass
|
|
1327
|
+
class InfoTable(Component):
|
|
1328
|
+
"""Key-value table with surface background, border, and row dividers.
|
|
1329
|
+
|
|
1330
|
+
Each row is a ``(key, value)`` tuple rendered in a fixed-width key column
|
|
1331
|
+
(monospace, green accent) and a value column (monospace, FG).
|
|
1332
|
+
|
|
1333
|
+
Example::
|
|
1334
|
+
|
|
1335
|
+
InfoTable([
|
|
1336
|
+
("app_id", "my-app"),
|
|
1337
|
+
("workspace", "/path/to/ws"),
|
|
1338
|
+
])
|
|
1339
|
+
"""
|
|
1340
|
+
rows: List[tuple] # list of (key_label, value_text)
|
|
1341
|
+
key_width: float = 100.0
|
|
1342
|
+
background: str = SURFACE
|
|
1343
|
+
border: str = HIGHLIGHT
|
|
1344
|
+
radius: float = RADIUS_MD
|
|
1345
|
+
|
|
1346
|
+
ROW_H = 30.0
|
|
1347
|
+
PAD_H = SPACE_MD
|
|
1348
|
+
|
|
1349
|
+
def measure(self, avail_w: float) -> float:
|
|
1350
|
+
if not self.rows:
|
|
1351
|
+
return 0.0
|
|
1352
|
+
return self.ROW_H * len(self.rows)
|
|
1353
|
+
|
|
1354
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1355
|
+
# Background + border
|
|
1356
|
+
ctx.rect(x, y, w, h, self.background, radius=self.radius)
|
|
1357
|
+
ctx.rect(x, y, w, 1.0, self.border)
|
|
1358
|
+
ctx.rect(x, y + h - 1.0, w, 1.0, self.border)
|
|
1359
|
+
ctx.rect(x, y, 1.0, h, self.border)
|
|
1360
|
+
ctx.rect(x + w - 1.0, y, 1.0, h, self.border)
|
|
1361
|
+
|
|
1362
|
+
val_x = x + self.PAD_H + self.key_width + self.PAD_H
|
|
1363
|
+
val_w = w - self.PAD_H - self.key_width - self.PAD_H * 2
|
|
1364
|
+
|
|
1365
|
+
for i, (key, value) in enumerate(self.rows):
|
|
1366
|
+
row_y = y + i * self.ROW_H
|
|
1367
|
+
cy = row_y + self.ROW_H / 2.0
|
|
1368
|
+
|
|
1369
|
+
# Key (green, monospace)
|
|
1370
|
+
ctx.text(x + self.PAD_H, cy, str(key),
|
|
1371
|
+
size=TEXT_CAPTION, color=GREEN, monospace=True,
|
|
1372
|
+
align="left_center", max_width=self.key_width, elide=True)
|
|
1373
|
+
|
|
1374
|
+
# Value (FG, monospace)
|
|
1375
|
+
ctx.text(val_x, cy, str(value),
|
|
1376
|
+
size=TEXT_CAPTION, color=FG, monospace=True,
|
|
1377
|
+
align="left_center", max_width=val_w, elide=True)
|
|
1378
|
+
|
|
1379
|
+
# Row divider (skip last row)
|
|
1380
|
+
if i < len(self.rows) - 1:
|
|
1381
|
+
div_y = row_y + self.ROW_H
|
|
1382
|
+
ctx.rect(x + 1.0, div_y, w - 2.0, 1.0, BG)
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
@dataclass
|
|
1386
|
+
class ButtonRow(Component):
|
|
1387
|
+
"""A clickable button rendered as a component in the declarative tree.
|
|
1388
|
+
|
|
1389
|
+
Delegates to ``ctx.button()`` for host-managed hover/click state.
|
|
1390
|
+
After ``ctx.render(column)``, check ``.clicked`` to see if the button
|
|
1391
|
+
was pressed this frame.
|
|
1392
|
+
|
|
1393
|
+
Example::
|
|
1394
|
+
|
|
1395
|
+
self._btn = ButtonRow("action", "Click me")
|
|
1396
|
+
|
|
1397
|
+
def on_render(self, ctx):
|
|
1398
|
+
ctx.render(Column([self._btn]))
|
|
1399
|
+
if self._btn.clicked:
|
|
1400
|
+
handle_click()
|
|
1401
|
+
"""
|
|
1402
|
+
id: str
|
|
1403
|
+
label: str
|
|
1404
|
+
text_color: str = ACCENT
|
|
1405
|
+
fill: str = SURFACE
|
|
1406
|
+
hover_fill: str = HIGHLIGHT
|
|
1407
|
+
active_fill: str = "#585b70"
|
|
1408
|
+
font_size: float = TEXT_BODY
|
|
1409
|
+
radius: float = RADIUS_MD
|
|
1410
|
+
height: float = 36.0
|
|
1411
|
+
clicked: bool = field(default=False, init=False, repr=False)
|
|
1412
|
+
|
|
1413
|
+
def measure(self, avail_w: float) -> float:
|
|
1414
|
+
return self.height
|
|
1415
|
+
|
|
1416
|
+
def render(self, ctx, x: float, y: float, w: float, h: float) -> None:
|
|
1417
|
+
self.clicked = ctx.button(
|
|
1418
|
+
id=self.id,
|
|
1419
|
+
x=x, y=y, w=w, h=h,
|
|
1420
|
+
label=self.label,
|
|
1421
|
+
fill=self.fill,
|
|
1422
|
+
hover_fill=self.hover_fill,
|
|
1423
|
+
active_fill=self.active_fill,
|
|
1424
|
+
text_color=self.text_color,
|
|
1425
|
+
font_size=self.font_size,
|
|
1426
|
+
radius=self.radius,
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
# ── ListView row helpers ───────────────────────────────────────────────────────
|
|
1431
|
+
|
|
1432
|
+
@dataclass
|
|
1433
|
+
class LeadingBadge:
|
|
1434
|
+
"""Badge leading slot for :class:`ListRow`.
|
|
1435
|
+
|
|
1436
|
+
Renders a pill badge with ``label`` text and the given ``color``.
|
|
1437
|
+
"""
|
|
1438
|
+
label: str
|
|
1439
|
+
color: str = "accent"
|
|
1440
|
+
|
|
1441
|
+
def to_dict(self) -> dict:
|
|
1442
|
+
return {"variant": "badge", "label": self.label, "color": self.color}
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
@dataclass
|
|
1446
|
+
class LeadingAvatar:
|
|
1447
|
+
"""Circular avatar leading slot for :class:`ListRow`.
|
|
1448
|
+
|
|
1449
|
+
``handle`` must be a UUID returned by ``emit.load_image(url)``.
|
|
1450
|
+
"""
|
|
1451
|
+
handle: str
|
|
1452
|
+
|
|
1453
|
+
def to_dict(self) -> dict:
|
|
1454
|
+
return {"variant": "avatar", "handle": self.handle}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
@dataclass
|
|
1458
|
+
class LeadingIcon:
|
|
1459
|
+
"""Text/emoji icon leading slot for :class:`ListRow`."""
|
|
1460
|
+
name: str
|
|
1461
|
+
|
|
1462
|
+
def to_dict(self) -> dict:
|
|
1463
|
+
return {"variant": "icon", "name": self.name}
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
@dataclass
|
|
1467
|
+
class RowChip:
|
|
1468
|
+
"""A small colored chip label on a :class:`ListRow`."""
|
|
1469
|
+
label: str
|
|
1470
|
+
color: str = "accent"
|
|
1471
|
+
|
|
1472
|
+
def to_dict(self) -> dict:
|
|
1473
|
+
return {"label": self.label, "color": self.color}
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
@dataclass
|
|
1477
|
+
class ListRow:
|
|
1478
|
+
"""Typed row descriptor for :meth:`RenderContext.list_view`.
|
|
1479
|
+
|
|
1480
|
+
Example::
|
|
1481
|
+
|
|
1482
|
+
rows = [
|
|
1483
|
+
ListRow(
|
|
1484
|
+
id=f"issue-{issue['number']}",
|
|
1485
|
+
leading=LeadingBadge(f"#{issue['number']}", color="accent"),
|
|
1486
|
+
primary=issue["title"],
|
|
1487
|
+
chips=[RowChip(lbl["name"], _label_color(lbl["name"])) for lbl in issue["labels"][:2]],
|
|
1488
|
+
).to_dict()
|
|
1489
|
+
for issue in self._issues
|
|
1490
|
+
]
|
|
1491
|
+
ctx.list_view("issues", rows, selected=self._sel, y=float(HEADER_H))
|
|
1492
|
+
"""
|
|
1493
|
+
id: str
|
|
1494
|
+
primary: str
|
|
1495
|
+
leading: "LeadingBadge | LeadingAvatar | LeadingIcon | None" = None
|
|
1496
|
+
secondary: "str | None" = None
|
|
1497
|
+
chips: "list[RowChip]" = field(default_factory=list)
|
|
1498
|
+
trailing: "str | None" = None
|
|
1499
|
+
|
|
1500
|
+
def to_dict(self) -> dict:
|
|
1501
|
+
return {
|
|
1502
|
+
"type": "row",
|
|
1503
|
+
"id": self.id,
|
|
1504
|
+
"leading": self.leading.to_dict() if self.leading else {"variant": "none"},
|
|
1505
|
+
"primary": self.primary,
|
|
1506
|
+
"secondary": self.secondary,
|
|
1507
|
+
"chips": [c.to_dict() for c in self.chips],
|
|
1508
|
+
"trailing": self.trailing,
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
__all__ = [
|
|
1513
|
+
# tokens
|
|
1514
|
+
"SPACE_XS", "SPACE_SM", "SPACE_MD", "SPACE_LG", "SPACE_XL",
|
|
1515
|
+
"TEXT_HINT", "TEXT_CAPTION", "TEXT_BODY", "TEXT_HEADING",
|
|
1516
|
+
"TEXT_TITLE", "TEXT_TITLE_XL",
|
|
1517
|
+
"RADIUS_SM", "RADIUS_MD", "RADIUS_LG", "RADIUS_BADGE",
|
|
1518
|
+
"BG", "SURFACE", "HIGHLIGHT", "ACCENT", "MUTED", "FG",
|
|
1519
|
+
"RED", "GREEN", "YELLOW",
|
|
1520
|
+
# components
|
|
1521
|
+
"Component", "Column", "Card",
|
|
1522
|
+
"AppBar", "Section", "KeyRow", "Heading", "Label",
|
|
1523
|
+
"Spacer", "Divider", "ScrollLog", "Scrollable", "Footer", "FooterKeys",
|
|
1524
|
+
"ListItem", "Row", "TextInput", "ChatBubble",
|
|
1525
|
+
"SelectList", "FormField",
|
|
1526
|
+
"InfoTable", "ButtonRow",
|
|
1527
|
+
# badge primitive
|
|
1528
|
+
"badge",
|
|
1529
|
+
# scroll helpers
|
|
1530
|
+
"ensure_visible",
|
|
1531
|
+
# entry
|
|
1532
|
+
"render_tree",
|
|
1533
|
+
# ListView row helpers
|
|
1534
|
+
"ListRow", "RowChip", "LeadingBadge", "LeadingAvatar", "LeadingIcon",
|
|
1535
|
+
]
|