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.
@@ -0,0 +1,976 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from ._constants import FG, BG, ACCENT, BODY
10
+ from ._emitter import _emit, _LOCK
11
+
12
+ if TYPE_CHECKING:
13
+ from ._app import App
14
+ from ._emitter import Emitter
15
+
16
+ COMPACT_DEFAULT: float = 280.0
17
+ REGULAR_DEFAULT: float = 480.0
18
+
19
+ # ── RenderContext ──────────────────────────────────────────────────────────────
20
+
21
+ class RenderContext:
22
+ """Passed to on_render. Accumulate draw commands; FrameDone is auto-emitted."""
23
+
24
+ def __init__(self, frame_id: int, rect: dict, workspace_root: str,
25
+ capabilities: "list[str]", feature_flags: "list[str]", app: "App",
26
+ elapsed: float = 0.0,
27
+ clicks: "list[tuple[float, float]] | None" = None):
28
+ self.frame_id = frame_id
29
+ self.x: float = rect.get("x", 0.0)
30
+ self.y: float = rect.get("y", 0.0)
31
+ self.w: float = rect.get("w", 800.0)
32
+ self.h: float = rect.get("h", 600.0)
33
+ self.workspace_root = workspace_root
34
+ self.capabilities = capabilities
35
+ self.feature_flags = feature_flags
36
+ self._app = app
37
+ self.emit: "Emitter" = app.emit
38
+ # Seconds elapsed since the previous on_render call. 0.0 on first frame.
39
+ # Use this for time-based game logic instead of calling time.time() yourself.
40
+ self.elapsed: float = elapsed
41
+ self._compact_threshold: float = COMPACT_DEFAULT
42
+ self._regular_threshold: float = REGULAR_DEFAULT
43
+ # Buffered JSON lines — flushed as a single write in frame_done().
44
+ self._buf: "list[str]" = []
45
+ # Mouse state for ctx.button() hit-testing (#255).
46
+ self._mx: float = app._mx
47
+ self._my: float = app._my
48
+ self._clicks: list[tuple[float, float]] = clicks or []
49
+ # Shared reference to App's hold-timer map — mutations here persist
50
+ # across frames (#1083). Prune expired entries each frame so the dict
51
+ # stays bounded for apps that generate transient button IDs.
52
+ self._btn_active_until: dict[str, float] = app._btn_active_until
53
+ _now = time.monotonic()
54
+ expired = [bid for bid, exp in self._btn_active_until.items() if exp <= _now]
55
+ for bid in expired:
56
+ del self._btn_active_until[bid]
57
+
58
+ def _queue(self, obj: dict) -> None:
59
+ """Buffer a render command for batch flush at frame_done()."""
60
+ self._buf.append(json.dumps(obj) + "\n")
61
+
62
+ @property
63
+ def width(self) -> float:
64
+ return self.w
65
+
66
+ @property
67
+ def height(self) -> float:
68
+ return self.h
69
+
70
+ @property
71
+ def size_class(self) -> str:
72
+ """Current size class based on pane width vs declared thresholds.
73
+
74
+ Returns 'compact', 'regular', or 'full'.
75
+ Thresholds come from the manifest [launch] section (or SDK defaults).
76
+ """
77
+ if self.w < self._compact_threshold:
78
+ return "compact"
79
+ elif self.w < self._regular_threshold:
80
+ return "regular"
81
+ return "full"
82
+
83
+ def declare_min_size(self, width: float, height: float) -> None:
84
+ """Override the manifest-declared minimum size at runtime.
85
+
86
+ Emits DrawCommand::SetMinSize. The host stores this and uses it as the
87
+ live effective minimum for this pane going forward, superseding the
88
+ manifest value. Use when a view mode needs more space than the default
89
+ (e.g. a detail view vs a list view).
90
+ """
91
+ self._queue({"type": "set_min_size", "width": width, "height": height})
92
+
93
+ # ── Visual primitives ──
94
+ def clear(self, fill: str = "#000000") -> None:
95
+ """Fill the entire pane with a single color. Shorthand for a full-size rect."""
96
+ self._queue({"type": "rect", "x": 0.0, "y": 0.0, "w": self.w, "h": self.h,
97
+ "fill": fill, "radius": 0.0})
98
+
99
+ def rect(self, x: float, y: float, w: float, h: float, fill: str,
100
+ radius: float = 0.0) -> None:
101
+ """Draw a filled rectangle.
102
+
103
+ Args:
104
+ x: Left edge in logical pixels
105
+ y: Top edge in logical pixels
106
+ w: Width in logical pixels
107
+ h: Height in logical pixels
108
+ fill: Fill color as hex string (e.g., "#ff0000" or "#ff0000aa" with alpha)
109
+ radius: Corner radius in pixels (0.0 = sharp corners, default)
110
+ """
111
+ self._queue({"type": "rect", "x": x, "y": y, "w": w, "h": h,
112
+ "fill": fill, "radius": radius})
113
+
114
+ def circle(self, cx: float, cy: float, r: float, fill: str) -> None:
115
+ """Draw a filled circle.
116
+
117
+ Args:
118
+ cx: Center X in logical pixels
119
+ cy: Center Y in logical pixels
120
+ r: Radius in logical pixels
121
+ fill: Fill color as hex string; supports 8-digit hex (#rrggbbaa) for alpha
122
+ """
123
+ self._queue({"type": "circle", "cx": cx, "cy": cy, "r": r, "fill": fill})
124
+
125
+ def arc(self, cx: float, cy: float, r: float,
126
+ start_angle: float, end_angle: float, fill: str) -> None:
127
+ """Draw a filled pie slice. Angles in radians, clockwise from east (right).
128
+ Full circle: start_angle=0, end_angle=6.2832 (2*pi).
129
+ Example pie slice: arc(cx, cy, r, 0, math.pi * 0.5, fill="#ff0000")"""
130
+ self._queue({"type": "arc", "cx": cx, "cy": cy, "r": r,
131
+ "start_angle": start_angle, "end_angle": end_angle, "fill": fill})
132
+
133
+ def text(self, x: float, y: float, text: str, size: float, color: str,
134
+ monospace: bool = False, bold: bool = False,
135
+ align: str = "top_left",
136
+ max_width: "float | None" = None,
137
+ elide: bool = True,
138
+ selectable: bool = False,
139
+ max_lines: "int | None" = None) -> None:
140
+ """Draw text. `align` controls how `(x, y)` maps to the text box:
141
+
142
+ - "top_left" (default) — (x, y) is the top-left corner.
143
+ - "center" — (x, y) is the visual center.
144
+ - "top_center" — (x, y) is the top-center.
145
+ - "right" — (x, y) is the top-right corner.
146
+
147
+ Use "center" when placing text inside a fixed-size container (a badge,
148
+ a button, a pie-chart label) — the host uses real font metrics, which
149
+ is noticeably more accurate than Python-side math with approximate
150
+ character-width ratios.
151
+
152
+ `max_width` — when set, the host clips the text at this pixel width.
153
+ `elide` — when True (default), a "…" is appended at the clip point;
154
+ when False, the text is hard-clipped with no marker.
155
+ `selectable` — when True, the host renders the text as a real egui
156
+ label so the user can drag-select inside it and Cmd+C
157
+ copies the current selection. Default False (#200).
158
+
159
+ `max_width`, `elide`, and `selectable` are sent explicitly on the wire
160
+ so the host always has required fields (no serde defaults). The SDK
161
+ fills in None / True / False when the caller omits them.
162
+ """
163
+ d: dict = {"type": "text", "x": x, "y": y, "text": text, "size": size,
164
+ "color": color, "monospace": monospace, "bold": bold,
165
+ "align": align, "max_width": max_width, "elide": elide,
166
+ "selectable": selectable}
167
+ if max_lines is not None:
168
+ d["max_lines"] = max_lines
169
+ self._queue(d)
170
+
171
+ def markdown(self, x: float, y: float, w: float, text: str,
172
+ base_size: float = 14.0, color: str = FG) -> None:
173
+ """Render markdown text via the host's egui_commonmark renderer.
174
+
175
+ The host creates a child Ui at (x, y) with width w and renders the
176
+ markdown with proper formatting (bold, italic, code blocks, lists, etc.).
177
+
178
+ `base_size` — body font size in pt (headers scale relative to this).
179
+ `color` — default text colour (hex).
180
+ """
181
+ self._queue({"type": "markdown", "x": x, "y": y, "w": w, "text": text,
182
+ "base_size": base_size, "color": color})
183
+
184
+ def image(self, src: str, x: float, y: float, w: float, h: float,
185
+ fit: str = "contain") -> None:
186
+ """Draw an image from a file path or URL.
187
+
188
+ `fit` controls scaling: "contain" (default, letterbox), "cover"
189
+ (crop to fill), or "fill" (stretch). Host must implement
190
+ DrawCommand::Image for this to render.
191
+ """
192
+ self._queue({"type": "image", "src": src, "x": x, "y": y, "w": w, "h": h,
193
+ "fit": fit})
194
+
195
+ def copy_to_clipboard(self, text: str) -> None:
196
+ """Write `text` to the OS clipboard via the host.
197
+
198
+ Uses _emit (immediate write) rather than _queue so this works from
199
+ on_key and other hook handlers where frame_done() is never called.
200
+ """
201
+ _emit({"type": "copy_to_clipboard", "text": text})
202
+
203
+ def badge(self, x: float, y_center: float, label: str,
204
+ fill: str = ACCENT, fg: str = BG,
205
+ font_size: float = 11.0, radius: float = 6.0) -> None:
206
+ """Render a host-measured pill badge.
207
+
208
+ The host measures the label with real egui font metrics, sizes the pill
209
+ (text_w + padding), and centres the text — no Python width math.
210
+
211
+ `x` — left edge of the badge.
212
+ `y_center` — vertical centre of the badge.
213
+ `label` — text to display.
214
+ `fill` — pill background colour.
215
+ `fg` — text colour.
216
+ `font_size`— label pt size.
217
+ `radius` — corner radius (8.0 = fully rounded pill; 4.0 = tag chip).
218
+ """
219
+ self._queue({"type": "badge", "x": x, "y": y_center, "label": label,
220
+ "fill": fill, "fg": fg, "font_size": font_size, "radius": radius})
221
+
222
+ def button(self, id: str, x: float, y: float, w: float, h: float,
223
+ label: str,
224
+ fill: str = "#313244",
225
+ hover_fill: str = "#45475a",
226
+ active_fill: str = "#585b70",
227
+ text_color: str = "#cdd6f4",
228
+ font_size: float = 13.0,
229
+ radius: float = 6.0) -> bool:
230
+ """Draw a button and return True if it was clicked this frame.
231
+
232
+ Hover state is derived from the current mouse position (requires
233
+ mouse_tracking = true in the manifest, or degrades gracefully without it).
234
+ Click state is derived from buffered click events since the previous frame.
235
+
236
+ `id` is currently unused but reserved for future focus tracking.
237
+ """
238
+ hovered = (x <= self._mx <= x + w) and (y <= self._my <= y + h)
239
+ clicked = any(
240
+ (x <= cx <= x + w) and (y <= cy <= y + h)
241
+ for cx, cy in self._clicks
242
+ )
243
+ now = time.monotonic()
244
+ if clicked:
245
+ self._btn_active_until[id] = now + 0.15
246
+ self.schedule_render(after_ms=150)
247
+ self.info(f"button clicked: id={id!r}")
248
+ active = now < self._btn_active_until.get(id, 0.0)
249
+ bg = active_fill if (clicked or active) else (hover_fill if hovered else fill)
250
+ self.rect(x, y, w, h, fill=bg, radius=radius)
251
+ self.text(x + w / 2, y + h / 2, label, size=font_size,
252
+ color=text_color, align="center")
253
+ return clicked
254
+
255
+ def key_chip_row(self, x: float, y: float, keys: "list[str]",
256
+ description: "str | None" = None,
257
+ font_size: float = 11.0) -> None:
258
+ """Render a row of keycap chips followed by an optional description.
259
+
260
+ The host measures each chip with real egui font metrics and flows them
261
+ left-to-right — no Python width math.
262
+
263
+ `x`, `y` — origin of the chip row (left edge, top of chips).
264
+ `keys` — list of key labels, e.g. ["⌘", "K"] for a chord.
265
+ `description`— optional trailing text label after the chips.
266
+ `font_size` — chip label pt size.
267
+
268
+ For multi-pair shortcut rows with horizontal flow + multi-line
269
+ wrapping, use `ctx.shortcuts(...)` instead — that's the right
270
+ primitive for footer-style "[k] desc · [j] desc · …" layouts.
271
+ """
272
+ self._queue({"type": "key_chip_row", "x": x, "y": y, "keys": keys,
273
+ "description": description, "font_size": font_size})
274
+
275
+ def text_row(self, x: float, y: float, items: "list[dict]",
276
+ gap: float = 8.0, align: str = "left_center") -> None:
277
+ """Render multiple text segments horizontally with configurable spacing.
278
+
279
+ The host measures each text segment with real font metrics and flows
280
+ them left-to-right — no Python width math.
281
+
282
+ `x`, `y` — origin of the text row.
283
+ `items` — list of text segment dicts, each with keys:
284
+ - `text` — string to display (required)
285
+ - `color` — color string (default: FG)
286
+ - `size` — font size in pt (default: BODY)
287
+ - `monospace` — bool (default: False)
288
+ `gap` — spacing between items in pixels (default: 8.0).
289
+ `align` — vertical alignment; use "left_center" to center items
290
+ on the y-axis at their midline (default: "left_center").
291
+
292
+ Example::
293
+
294
+ ctx.text_row(
295
+ x=24.0, y=200.0,
296
+ items=[
297
+ {"text": "12:34:56", "color": "#6c7086", "size": CAPTION, "monospace": True},
298
+ {"text": "key pressed", "color": FG, "size": CAPTION},
299
+ ],
300
+ gap=12.0,
301
+ )
302
+ """
303
+ # Validate and normalize items: each dict must have 'text', other keys optional.
304
+ wire_items = []
305
+ for item in items:
306
+ if not isinstance(item, dict) or "text" not in item:
307
+ raise ValueError("Each item in text_row must be a dict with 'text' key")
308
+ wire_items.append({
309
+ "text": item["text"],
310
+ "color": item.get("color", FG),
311
+ "size": item.get("size", BODY),
312
+ "monospace": item.get("monospace", False),
313
+ })
314
+ self._queue({"type": "text_row", "x": x, "y": y, "items": wire_items,
315
+ "gap": gap, "align": align})
316
+
317
+ def shortcuts(self, x: float, y: float, max_width: float,
318
+ pairs: "list[tuple]", font_size: float = 11.0) -> None:
319
+ """Render a multi-group shortcut row with host-measured layout.
320
+
321
+ The host owns ALL geometry: chip widths from real font metrics,
322
+ horizontal flow with inter-group spacing, multi-line wrapping
323
+ when the next group would exceed `max_width`. SDK callers send
324
+ one DrawCommand and trust the result — no Python width math,
325
+ no truncation, no overflow.
326
+
327
+ `pairs` is a list of `(keys, description)` tuples where `keys`
328
+ is either a single string or a list of strings (multi-key
329
+ chord). Example::
330
+
331
+ ctx.shortcuts(
332
+ x=24.0, y=12.0, max_width=ctx.w - 48.0,
333
+ pairs=[
334
+ (["[", "]"], "week"),
335
+ ("t", "today"),
336
+ (["j", "k"], "commit"),
337
+ ("?", "help"),
338
+ ],
339
+ )
340
+ """
341
+ # Normalise (keys-or-key, desc) → ShortcutPair on the wire.
342
+ wire_pairs = []
343
+ for keys_or_key, desc in pairs:
344
+ if isinstance(keys_or_key, str):
345
+ wire_keys = [keys_or_key]
346
+ else:
347
+ wire_keys = list(keys_or_key)
348
+ wire_pairs.append({"keys": wire_keys, "description": desc or ""})
349
+ self._queue({"type": "shortcuts", "x": x, "y": y,
350
+ "max_width": max_width, "pairs": wire_pairs,
351
+ "font_size": font_size})
352
+
353
+ async def measure_text(self, text: str, font_size: float,
354
+ monospace: bool = False) -> "tuple[float, float]":
355
+ """Measure `text` at `font_size` using the host's real font metrics.
356
+
357
+ Sends a `MeasureText` DrawCommand and awaits `TextMeasured` from the
358
+ host. Returns `(width, height)` in logical pixels.
359
+
360
+ Use this only when layout depends on measured text width (e.g. flowing
361
+ multiple badges horizontally). Avoid on hot render paths — prefer
362
+ passing `max_width` on `ctx.text()` for simple truncation.
363
+
364
+ Must be called with ``await`` from an async hook.
365
+ """
366
+ import uuid
367
+ request_id = str(uuid.uuid4())
368
+ from ._emitter import _make_async_queue
369
+ q: asyncio.Queue[tuple[float, float]] = _make_async_queue()
370
+ self._app._pending_measure_text[request_id] = q
371
+ # Flush any buffered draw commands before the request so the host
372
+ # processes prior frame content before responding to the measure.
373
+ if self._buf:
374
+ with _LOCK:
375
+ sys.stdout.write("".join(self._buf))
376
+ sys.stdout.flush()
377
+ self._buf.clear()
378
+ _emit({"type": "measure_text", "request_id": request_id,
379
+ "text": text, "font_size": font_size, "monospace": monospace})
380
+ return await q.get()
381
+
382
+ async def measure_text_wrapped(self, text: str, font_size: float,
383
+ max_width: float,
384
+ max_lines: "int | None" = None) -> float:
385
+ """Measure the height of `text` wrapped at `max_width` using real host font metrics.
386
+
387
+ If `max_lines` is set, clamps the result to that many rows.
388
+ Returns height in logical pixels.
389
+
390
+ Call at data-load time (once per item), not inside on_render() — each call
391
+ is an async IPC roundtrip.
392
+ """
393
+ import uuid as _uuid
394
+ request_id = str(_uuid.uuid4())
395
+ from ._emitter import _make_async_queue
396
+ q: asyncio.Queue[float] = _make_async_queue()
397
+ self._app._pending_measure_text_wrapped[request_id] = q
398
+ if self._buf:
399
+ with _LOCK:
400
+ sys.stdout.write("".join(self._buf))
401
+ sys.stdout.flush()
402
+ self._buf.clear()
403
+ payload: dict = {"type": "measure_text_wrapped", "request_id": request_id,
404
+ "text": text, "font_size": font_size, "max_width": max_width}
405
+ if max_lines is not None:
406
+ payload["max_lines"] = max_lines
407
+ _emit(payload)
408
+ try:
409
+ return await asyncio.wait_for(q.get(), timeout=10.0)
410
+ except asyncio.TimeoutError:
411
+ self._app._pending_measure_text_wrapped.pop(request_id, None)
412
+ raise RuntimeError(
413
+ f"measure_text_wrapped timed out after 10s (request_id={request_id!r})"
414
+ )
415
+
416
+ def avatar(self, src: str, x: float, y_center: float, radius: float) -> None:
417
+ """Render a circular clipped image.
418
+
419
+ `src` accepts a handle from `emit.load_image()` or a local file path.
420
+ `x` is the left edge of the circle's bounding box (circle centre = x + radius).
421
+ `y_center` is the vertical centre of the circle.
422
+ `radius` is the circle radius in logical pixels.
423
+ """
424
+ self._queue({"type": "avatar", "src": src,
425
+ "cx": x + radius, "cy": y_center, "radius": radius})
426
+
427
+ def skeleton(self, x: float, y: float, w: float, h: float,
428
+ radius: float = 4.0) -> None:
429
+ """Render an animated shimmer placeholder rect for loading states.
430
+
431
+ The host drives a ~20fps pulsing animation — no app-side timer needed.
432
+ """
433
+ self._queue({"type": "skeleton", "x": x, "y": y, "w": w, "h": h,
434
+ "radius": radius})
435
+
436
+ def push_clip(self, x: float, y: float, w: float, h: float) -> None:
437
+ """Push a clip rect onto the host's clip stack.
438
+
439
+ All subsequent draws are clipped to the intersection of this rect with
440
+ the current top of the stack (or the pane rect if the stack is empty).
441
+ Must be balanced with a matching `pop_clip()`. Use `_render_clipped`
442
+ on Component subclasses instead of calling this directly.
443
+ """
444
+ self._queue({"type": "push_clip", "x": x, "y": y, "w": w, "h": h})
445
+
446
+ def pop_clip(self) -> None:
447
+ """Pop the most recently pushed clip rect. Must balance a `push_clip()`."""
448
+ self._queue({"type": "pop_clip"})
449
+
450
+ def line(self, x1: float, y1: float, x2: float, y2: float,
451
+ color: str, width: float = 1.0) -> None:
452
+ """Draw a line segment.
453
+
454
+ Args:
455
+ x1: Start X in logical pixels
456
+ y1: Start Y in logical pixels
457
+ x2: End X in logical pixels
458
+ y2: End Y in logical pixels
459
+ color: Line color as hex string
460
+ width: Line width in logical pixels (default: 1.0)
461
+ """
462
+ self._queue({"type": "line", "x1": x1, "y1": y1, "x2": x2, "y2": y2,
463
+ "color": color, "width": width})
464
+
465
+ # ── SDK v2 declarative UI entry point ──
466
+ def render(self, tree: Any, fill: str = "#1e1e2e") -> None:
467
+ """Render a declarative UI tree (see `plexi_sdk.ui`). Clears the pane
468
+ to `fill` first, then lays out `tree` into the full pane rect.
469
+
470
+ Example:
471
+ from plexi_sdk.ui import Column, Header, Footer, Spacer
472
+ ctx.render(Column([
473
+ Header("My App"),
474
+ Spacer(grow=True),
475
+ Footer("status line"),
476
+ ]))
477
+ """
478
+ # Local import avoids a circular dependency at module load time
479
+ # (ui.py references RenderContext only through duck-typed `ctx`).
480
+ from plexi_sdk.ui import render_tree
481
+ render_tree(self, tree, fill=fill)
482
+
483
+ def simple_list(self, items: "list[dict]", selected: int = 0,
484
+ item_height: float = 40.0, x: float = 0.0, y: float = 0.0,
485
+ w: "float | None" = None, h: "float | None" = None) -> None:
486
+ """Draw a scrollable list of items with optional selection highlight.
487
+
488
+ Args:
489
+ items: List of item dicts, each with keys "text" (required) and optional "disabled"
490
+ selected: Index of the highlighted item (0-indexed)
491
+ item_height: Height of each item in logical pixels
492
+ x: Left edge of the list
493
+ y: Top edge of the list
494
+ w: Width of the list (defaults to full pane width)
495
+ h: Height of the list (defaults to remaining pane height below y)
496
+ """
497
+ self._queue({"type": "list", "x": x, "y": y,
498
+ "w": self.w if w is None else w,
499
+ "h": self.h - y if h is None else h,
500
+ "items": items, "selected": selected,
501
+ "item_height": item_height})
502
+
503
+ def list_view(
504
+ self,
505
+ list_id: str,
506
+ items: "list[dict]",
507
+ selected: int = 0,
508
+ loading: bool = False,
509
+ error: "str | None" = None,
510
+ x: float = 0.0,
511
+ y: float = 0.0,
512
+ w: float = 0.0,
513
+ h: float = 0.0,
514
+ ) -> None:
515
+ """Host-native scrollable list with j/k navigation and typed row slots.
516
+
517
+ Items must be dicts with ``"type": "row"`` or ``"type": "custom_cell"``.
518
+ Use :class:`plexi_sdk.ui.ListRow` to build row descriptors.
519
+
520
+ The host handles: j/k selection, scroll-to-selected, skeleton loading
521
+ rows (when ``loading=True``), error text, and empty state.
522
+ The app receives :meth:`on_list_select` / :meth:`on_list_activate`
523
+ callbacks instead of managing scroll state.
524
+
525
+ Args:
526
+ list_id: Stable identifier for this list (must not change across frames).
527
+ items: List of row/custom_cell dicts. Use ``ListRow(...).to_dict()``.
528
+ selected: Index of the currently highlighted item.
529
+ loading: When True, renders skeleton rows instead of items.
530
+ error: When set, renders an error message instead of items.
531
+ x: Left edge in pane-local coordinates (0 = left edge).
532
+ y: Top edge in pane-local coordinates (0 = top of pane).
533
+ w: Width (0 = full pane width).
534
+ h: Height (0 = remaining height below y).
535
+ """
536
+ self._queue({
537
+ "type": "list_view",
538
+ "id": list_id,
539
+ "x": x,
540
+ "y": y,
541
+ "w": w,
542
+ "h": h,
543
+ "items": items,
544
+ "selected": selected,
545
+ "loading": loading,
546
+ "error": error,
547
+ })
548
+
549
+ def begin_scroll(self, id: str, x: float, y: float, w: float, h: float,
550
+ content_height: float) -> None:
551
+ """Begin a host-managed vertical scroll region (#446).
552
+
553
+ Declares a viewport at (x, y, w, h) within this pane. This rect does
554
+ two things: it clips all draw commands until the matching `end_scroll()`
555
+ call, AND it defines where the host detects scroll gestures. The host
556
+ only fires `on_scroll` when the pointer is inside this rect.
557
+
558
+ **Common mistake:** if your scrollable list occupies only part of the
559
+ pane (e.g. the right half), set x/w to cover the full pane width anyway
560
+ so the user can scroll from anywhere — not just when hovering over the
561
+ list. Clipping is only applied to content drawn *inside* the block, so
562
+ content drawn before `begin_scroll` is unaffected regardless of x/w.
563
+
564
+ The host tracks the scroll position across frames and calls
565
+ `on_scroll(ctx, id, offset_y)` whenever the user scrolls so the app
566
+ can re-render content translated by `offset_y`.
567
+
568
+ `content_height` is the total virtual height of the scrollable content
569
+ in logical pixels — the host uses this to size the scrollbar thumb.
570
+ Pass the full height of all items even if most are off-screen.
571
+
572
+ `id` must be stable across frames — use a descriptive string rather
573
+ than a counter. Typical pattern::
574
+
575
+ def on_scroll(self, ctx, id, offset_y):
576
+ if id == "main-list":
577
+ self.scroll_y = offset_y
578
+
579
+ def on_render(self, ctx):
580
+ ctx.begin_scroll("main-list", 0, 48, ctx.w, ctx.h - 48,
581
+ content_height=100 * ROW_H)
582
+ for i, item in enumerate(self.items):
583
+ y = i * ROW_H - self.scroll_y
584
+ ctx.text(8, y, item, size=14, color=FG)
585
+ ctx.end_scroll()
586
+ """
587
+ self._queue({"type": "begin_scroll", "id": id, "x": x, "y": y,
588
+ "w": w, "h": h, "content_height": content_height})
589
+
590
+ def end_scroll(self) -> None:
591
+ """Close the most recently opened scroll region. Must balance begin_scroll."""
592
+ self._queue({"type": "end_scroll"})
593
+
594
+ def text_input(self, id: str, x: float, y: float, w: float,
595
+ placeholder: str = "",
596
+ multiline: bool = False,
597
+ h: float = 24.0) -> "str | None":
598
+ """Text input — host-owned buffer, submit-only.
599
+
600
+ Emits a `DrawCommand::TextInput` and returns the most recently
601
+ submitted value for `id` if any landed since the previous frame,
602
+ else `None`. The host owns the buffer entirely — typed
603
+ characters never reach the app between keystrokes. On Enter the
604
+ host emits `PlexiEvent::TextSubmitted { id, value }` and clears
605
+ its buffer.
606
+
607
+ The host auto-focuses the widget on its first visible frame so
608
+ the user can type immediately without clicking.
609
+
610
+ When `multiline=True`, Enter submits and Shift+Enter inserts a
611
+ newline. When `multiline=False` (default), Enter submits
612
+ immediately.
613
+
614
+ Pattern (poll on every frame)::
615
+
616
+ submitted = ctx.text_input("note", x=12, y=12, w=300,
617
+ placeholder="Type a note…")
618
+ if submitted is not None:
619
+ save_note(submitted)
620
+
621
+ Real-time validation (per-keystroke access) is out of scope —
622
+ see issue #283.
623
+ """
624
+ self._queue({"type": "text_input", "id": id, "x": x, "y": y, "w": w,
625
+ "h": h, "placeholder": placeholder, "multiline": multiline})
626
+ return self._app._take_text_submission(id)
627
+
628
+ # Logging helpers (in-frame, forwarded to host logger)
629
+ def log(self, level: str, message: str) -> None:
630
+ _emit({"type": "log", "level": level, "message": message})
631
+
632
+ def info(self, message: str) -> None: self.log("info", message)
633
+ def warn(self, message: str) -> None: self.log("warn", message)
634
+ def error(self, message: str) -> None: self.log("error", message)
635
+ def debug(self, message: str) -> None: self.log("debug", message)
636
+
637
+ def notify(self, title: str, priority: int, body: str = "",
638
+ level: str = "info",
639
+ actions: "list | None" = None,
640
+ image_inline: "dict | None" = None,
641
+ image_pipe_id: "str | None" = None,
642
+ timeout_secs: "int | None" = None,
643
+ on_dismiss: "str | None" = None) -> None:
644
+ """Post a message notification. See Emitter.notify.
645
+ `priority` is required (int, higher = more urgent).
646
+ `image_inline` / `image_pipe_id` (#74) optionally attach an image.
647
+ Scope is resolved from the app's manifest — not an argument."""
648
+ self.emit.notify(title=title, priority=priority, body=body, level=level,
649
+ actions=actions,
650
+ image_inline=image_inline, image_pipe_id=image_pipe_id,
651
+ timeout_secs=timeout_secs, on_dismiss=on_dismiss)
652
+
653
+ async def notify_choice(self, title: str, options: list, priority: int,
654
+ body: str = "",
655
+ level: str = "info", required: bool = False,
656
+ image_inline: "dict | None" = None,
657
+ image_pipe_id: "str | None" = None,
658
+ timeout_secs: "int | None" = None,
659
+ on_dismiss: "str | None" = None) -> str:
660
+ """Post a choice notification and await until the user picks.
661
+ `priority` is required (int, higher = more urgent).
662
+ `image_inline` / `image_pipe_id` (#74) optionally attach an image.
663
+ Returns the chosen option's value (or label if no value set),
664
+ or "__cancel__" if the user dismissed. Use with ``await``."""
665
+ return await self.emit.notify_choice(title=title, options=options,
666
+ priority=priority, body=body,
667
+ level=level, required=required,
668
+ image_inline=image_inline,
669
+ image_pipe_id=image_pipe_id,
670
+ timeout_secs=timeout_secs,
671
+ on_dismiss=on_dismiss)
672
+
673
+ async def notify_with_image(self, title: str, body: str, image_bytes: bytes,
674
+ mime: str, priority: int,
675
+ level: str = "info",
676
+ choices: "list | None" = None) -> "str | None":
677
+ """Convenience: post a notification with an inline base64 image.
678
+ See Emitter.notify_with_image — handles base64 + 50KB cap. Use with ``await``."""
679
+ return await self.emit.notify_with_image(title=title, body=body,
680
+ image_bytes=image_bytes, mime=mime,
681
+ priority=priority, level=level,
682
+ choices=choices)
683
+
684
+ async def notify_input(self, title: str, priority: int,
685
+ prompt: str = "", body: str = "",
686
+ level: str = "info", required: bool = False,
687
+ timeout_secs: "int | None" = None,
688
+ on_dismiss: "str | None" = None) -> str:
689
+ """Post an input notification and await until the user submits.
690
+ `priority` is required (int, higher = more urgent).
691
+ Returns the typed text, or "__cancel__" if dismissed. Use with ``await``."""
692
+ return await self.emit.notify_input(title=title, priority=priority,
693
+ prompt=prompt, body=body,
694
+ level=level, required=required,
695
+ timeout_secs=timeout_secs,
696
+ on_dismiss=on_dismiss)
697
+
698
+ async def notify_and_wait(self, title: str, priority: int,
699
+ body: str = "", level: str = "info",
700
+ actions: "list | None" = None,
701
+ timeout_secs: "int | None" = None,
702
+ on_dismiss: "str | None" = None) -> str:
703
+ """Post a message notification and await for acknowledge/cancel.
704
+ `priority` is required (int, higher = more urgent).
705
+ See Emitter.notify_and_wait. Use with ``await``."""
706
+ return await self.emit.notify_and_wait(title=title, priority=priority,
707
+ body=body, level=level,
708
+ actions=actions,
709
+ timeout_secs=timeout_secs,
710
+ on_dismiss=on_dismiss)
711
+
712
+ def notify_async(self, title: str, priority: int, body: str = "",
713
+ level: str = "info",
714
+ actions: "list | None" = None,
715
+ image_inline: "dict | None" = None,
716
+ image_pipe_id: "str | None" = None,
717
+ timeout_secs: "int | None" = None,
718
+ on_dismiss: "str | None" = None,
719
+ on_response: "Any" = None) -> str:
720
+ """Non-blocking notify_and_wait. Returns immediately with notify_id.
721
+ `priority` is required. See Emitter.notify_async."""
722
+ return self.emit.notify_async(title=title, priority=priority, body=body,
723
+ level=level, actions=actions,
724
+ image_inline=image_inline,
725
+ image_pipe_id=image_pipe_id,
726
+ timeout_secs=timeout_secs,
727
+ on_dismiss=on_dismiss,
728
+ on_response=on_response)
729
+
730
+ def notify_choice_async(self, title: str, options: list, priority: int,
731
+ body: str = "",
732
+ level: str = "info", required: bool = False,
733
+ image_inline: "dict | None" = None,
734
+ image_pipe_id: "str | None" = None,
735
+ timeout_secs: "int | None" = None,
736
+ on_dismiss: "str | None" = None,
737
+ on_response: "Any" = None) -> str:
738
+ """Non-blocking notify_choice. Returns immediately with notify_id.
739
+ `priority` is required. See Emitter.notify_choice_async."""
740
+ return self.emit.notify_choice_async(title=title, options=options,
741
+ priority=priority, body=body,
742
+ level=level, required=required,
743
+ image_inline=image_inline,
744
+ image_pipe_id=image_pipe_id,
745
+ timeout_secs=timeout_secs,
746
+ on_dismiss=on_dismiss,
747
+ on_response=on_response)
748
+
749
+ def notify_input_async(self, title: str, priority: int,
750
+ prompt: str = "", body: str = "",
751
+ level: str = "info", required: bool = False,
752
+ timeout_secs: "int | None" = None,
753
+ on_dismiss: "str | None" = None,
754
+ on_response: "Any" = None) -> str:
755
+ """Non-blocking notify_input. Returns immediately with notify_id.
756
+ `priority` is required. See Emitter.notify_input_async."""
757
+ return self.emit.notify_input_async(title=title, priority=priority,
758
+ prompt=prompt, body=body,
759
+ level=level, required=required,
760
+ timeout_secs=timeout_secs,
761
+ on_dismiss=on_dismiss,
762
+ on_response=on_response)
763
+
764
+ def notify_and_wait_async(self, title: str, priority: int,
765
+ body: str = "", level: str = "info",
766
+ actions: "list | None" = None,
767
+ timeout_secs: "int | None" = None,
768
+ on_dismiss: "str | None" = None,
769
+ on_response: "Any" = None) -> str:
770
+ """Non-blocking notify_and_wait. Returns immediately with notify_id.
771
+ `priority` is required. See Emitter.notify_and_wait_async."""
772
+ return self.emit.notify_and_wait_async(title=title, priority=priority,
773
+ body=body, level=level,
774
+ actions=actions,
775
+ timeout_secs=timeout_secs,
776
+ on_dismiss=on_dismiss,
777
+ on_response=on_response)
778
+
779
+ def status_summary(self, text: str) -> None:
780
+ _emit({"type": "status_summary", "text": text})
781
+
782
+ def set_timer(self, timer_id: str, after_ms: int) -> None:
783
+ self.emit.set_timer(timer_id, after_ms)
784
+
785
+ def cancel_timer(self, timer_id: str) -> None:
786
+ self.emit.cancel_timer(timer_id)
787
+
788
+ def set_mouse_tracking(self, enabled: bool) -> None:
789
+ """Enable or disable on_mouse_move delivery for this pane.
790
+ Call with True in on_init to start receiving mouse-move events.
791
+ Delegates to emit.set_mouse_tracking()."""
792
+ self.emit.set_mouse_tracking(enabled)
793
+
794
+ def schedule_render(self, after_ms: int = 16) -> None:
795
+ """Ask the host to send a new Render event after `after_ms` milliseconds.
796
+ Delegates to emit.schedule_render(). 16 ms ≈ 60 fps. 32 ms ≈ 30 fps."""
797
+ self.emit.schedule_render(after_ms=after_ms)
798
+
799
+ async def get_secret(self, key: str) -> "str | None":
800
+ """Request a secret by key. Alias for emit.get_secret(). Use with ``await``."""
801
+ return await self.emit.get_secret(key)
802
+
803
+ def load_state(self) -> dict:
804
+ """Return persisted app state loaded at startup. {} if nothing saved."""
805
+ return dict(self._app._app_state)
806
+
807
+ def save_state(self, state: dict) -> None:
808
+ """Persist state to workspace (if workspace active) or global storage.
809
+ Fire-and-forget — no acknowledgement from host."""
810
+ _emit({"type": "save_app_state", "payload": state})
811
+
812
+ async def http_request(self, url: str, method: str = "GET",
813
+ headers: "dict[str, str] | None" = None,
814
+ body: "str | None" = None) -> str:
815
+ """HTTP request brokered through the host. Requires net.http capability. Use with ``await``."""
816
+ return await self.emit.http_request(url, method=method, headers=headers, body=body)
817
+
818
+ async def ai_query(self, model_tier: str, system: str,
819
+ messages: "list[dict]",
820
+ tools: "list[dict] | None" = None) -> object:
821
+ """AI broker call through the host. Requires ai.query capability. Use with ``await``."""
822
+ return await self.emit.ai_query(model_tier=model_tier, system=system,
823
+ messages=messages, tools=tools)
824
+
825
+ def spawn_pane(self, type_id: str, layout: str = "split_v",
826
+ args: "list[str] | None" = None,
827
+ pipe_id: "str | None" = None,
828
+ from_pane_id: "int | None" = None,
829
+ request_id: "str | None" = None,
830
+ target_context: "int | None" = None) -> None:
831
+ """Request the host to open a new pane. Requires panes.spawn capability."""
832
+ self.emit.spawn_pane(type_id, layout=layout, args=args, pipe_id=pipe_id,
833
+ from_pane_id=from_pane_id, request_id=request_id,
834
+ target_context=target_context)
835
+
836
+ def query_context_state(self, context_id: int) -> None:
837
+ """Query the state of a context. Host responds via on_context_state."""
838
+ self.emit.query_context_state(context_id)
839
+
840
+ # ── Declarative layout ──────────────────────────────────────────────────────
841
+
842
+ def badge_child(self, label: str, fill: str = "#89b4fa", fg: str = "#1e1e2e",
843
+ font_size: float = 11.0, radius: float = 8.0) -> dict:
844
+ """Return a layout child node for a Badge. Use inside ctx.row/column/stack."""
845
+ return {"type": "leaf", "command": {
846
+ "type": "badge", "x": 0.0, "y": 0.0,
847
+ "label": label, "fill": fill, "fg": fg,
848
+ "font_size": font_size, "radius": radius,
849
+ }}
850
+
851
+ def text_child(self, text: str, size: float = 12.0, color: str = "#cdd6f4",
852
+ monospace: bool = False, bold: bool = False,
853
+ max_width: "float | None" = None, elide: bool = False,
854
+ selectable: bool = False) -> dict:
855
+ """Return a layout child node for Text. Use inside ctx.row/column/stack."""
856
+ return {"type": "leaf", "command": {
857
+ "type": "text", "x": 0.0, "y": 0.0,
858
+ "text": text, "size": size, "color": color,
859
+ "monospace": monospace, "bold": bold,
860
+ "align": "top_left", "max_width": max_width,
861
+ "elide": elide, "selectable": selectable,
862
+ }}
863
+
864
+ def key_chip_child(self, label: str, font_size: float = 11.0) -> dict:
865
+ """Return a layout child node for a KeyChip. Use inside ctx.row/column/stack."""
866
+ return {"type": "leaf", "command": {
867
+ "type": "key_chip", "x": 0.0, "y": 0.0,
868
+ "label": label, "font_size": font_size,
869
+ }}
870
+
871
+ def layout_node(self, direction: str, children: "list[dict]", gap: float = 0.0) -> dict:
872
+ """Return a nested layout node for use inside ctx.row/column/stack."""
873
+ return {"type": "node", "direction": direction, "children": children, "gap": gap}
874
+
875
+ def row(self, x: float, y: float, children: "list[dict]", gap: float = 6.0) -> None:
876
+ """Emit a declarative flex-row layout tree.
877
+
878
+ The host resolves all child positions using taffy (flexbox) and real
879
+ egui font metrics. Children are layout child dicts from badge_child(),
880
+ text_child(), key_chip_child(), or layout_node().
881
+
882
+ `x`, `y` — pane-relative top-left anchor.
883
+ `children` — list of layout child dicts.
884
+ `gap` — space between children in pixels (default 6.0).
885
+
886
+ Example::
887
+
888
+ ctx.row(
889
+ x=24.0, y=40.0,
890
+ children=[
891
+ ctx.badge_child("4 files"),
892
+ ctx.text_child(" modified"),
893
+ ],
894
+ gap=6.0,
895
+ )
896
+ """
897
+ self._queue({
898
+ "type": "layout",
899
+ "x": x, "y": y,
900
+ "direction": "row",
901
+ "children": children,
902
+ "gap": gap,
903
+ })
904
+
905
+ def column(self, x: float, y: float, children: "list[dict]", gap: float = 6.0) -> None:
906
+ """Emit a declarative flex-column layout tree.
907
+
908
+ Same as row() but stacks children top-to-bottom.
909
+ """
910
+ self._queue({
911
+ "type": "layout",
912
+ "x": x, "y": y,
913
+ "direction": "column",
914
+ "children": children,
915
+ "gap": gap,
916
+ })
917
+
918
+ def stack(self, x: float, y: float, children: "list[dict]") -> None:
919
+ """Emit a declarative stack layout — all children rendered at the same origin.
920
+
921
+ Use for layering (background rect behind text, etc.).
922
+ """
923
+ self._queue({
924
+ "type": "layout",
925
+ "x": x, "y": y,
926
+ "direction": "stack",
927
+ "children": children,
928
+ "gap": 0.0,
929
+ })
930
+
931
+ def responsive(
932
+ self,
933
+ x: float,
934
+ y: float,
935
+ tiers: "list[dict]",
936
+ ) -> None:
937
+ """Emit a responsive layout that adapts to the available pane aspect ratio.
938
+
939
+ The host evaluates tiers in order and renders the first match:
940
+ - "landscape": pane is wider than tall (ratio > 1.2)
941
+ - "portrait": pane is taller than wide (ratio < 0.83)
942
+ - "square": fallback for balanced aspect ratios
943
+
944
+ Each tier dict has: aspect (str), direction (str), children (list), gap (float).
945
+
946
+ Example::
947
+
948
+ ctx.responsive(
949
+ x=0.0, y=0.0,
950
+ tiers=[
951
+ {"aspect": "landscape", "direction": "row",
952
+ "children": [ctx.text_child("wide view")], "gap": 6.0},
953
+ {"aspect": "portrait", "direction": "column",
954
+ "children": [ctx.text_child("tall view")], "gap": 4.0},
955
+ {"aspect": "square", "direction": "column",
956
+ "children": [ctx.text_child("default")], "gap": 4.0},
957
+ ],
958
+ )
959
+ """
960
+ self._queue({
961
+ "type": "responsive",
962
+ "x": x, "y": y,
963
+ "tiers": tiers,
964
+ })
965
+
966
+ def frame_done(self) -> None:
967
+ """Flush all buffered draw commands for this frame.
968
+
969
+ Called automatically after on_render returns. Only call this manually
970
+ if you're rendering in multiple phases and need intermediate updates.
971
+ """
972
+ self._buf.append(json.dumps({"type": "frame_done", "frame_id": self.frame_id}) + "\n")
973
+ with _LOCK:
974
+ sys.stdout.write("".join(self._buf))
975
+ sys.stdout.flush()
976
+ self._buf.clear()