plexi-sdk 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexi_sdk/__init__.py +477 -0
- plexi_sdk/_app.py +1077 -0
- plexi_sdk/_constants.py +52 -0
- plexi_sdk/_emitter.py +1466 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +976 -0
- plexi_sdk/_types.py +139 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/app_init.py +72 -0
- plexi_sdk/testing.py +451 -0
- plexi_sdk/ui.py +1535 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +60 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +159 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +218 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.4.0.dist-info/METADATA +127 -0
- plexi_sdk-0.4.0.dist-info/RECORD +25 -0
- plexi_sdk-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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()
|