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/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
+ ]