plexi-sdk 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexi_sdk/__init__.py +477 -0
- plexi_sdk/_app.py +1077 -0
- plexi_sdk/_constants.py +52 -0
- plexi_sdk/_emitter.py +1466 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +976 -0
- plexi_sdk/_types.py +139 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/app_init.py +72 -0
- plexi_sdk/testing.py +451 -0
- plexi_sdk/ui.py +1535 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +60 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +159 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +218 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.4.0.dist-info/METADATA +127 -0
- plexi_sdk-0.4.0.dist-info/RECORD +25 -0
- plexi_sdk-0.4.0.dist-info/WHEEL +4 -0
plexi_sdk/testing.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Headless snapshot testing for Plexi widgets.
|
|
2
|
+
|
|
3
|
+
Spawns the plexi binary with `--render`, feeds it DrawCommand JSON,
|
|
4
|
+
reads back PNG bytes. Provides pixel-level assertions and snapshot file helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import zlib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DEFAULT_BG = "#1e1e2e"
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Binary location
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def _find_renderer() -> str:
|
|
25
|
+
"""Locate the plexi binary to invoke with `--render`.
|
|
26
|
+
|
|
27
|
+
Checks in order:
|
|
28
|
+
1. PLEXI_RENDER_BIN env var (path to a plexi binary)
|
|
29
|
+
2. <repo_root>/target/release/plexi
|
|
30
|
+
3. <repo_root>/target/debug/plexi
|
|
31
|
+
|
|
32
|
+
Raises RuntimeError with build instructions if not found.
|
|
33
|
+
"""
|
|
34
|
+
env_override = os.environ.get("PLEXI_RENDER_BIN")
|
|
35
|
+
if env_override:
|
|
36
|
+
if os.path.isfile(env_override) and os.access(env_override, os.X_OK):
|
|
37
|
+
return env_override
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"PLEXI_RENDER_BIN is set to {env_override!r} but the file is not "
|
|
40
|
+
"executable or does not exist."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Resolve repo root relative to this file: sdk/python/plexi_sdk/testing.py
|
|
44
|
+
# → walk up three parents to reach the repo root.
|
|
45
|
+
repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
|
46
|
+
release = repo_root / "target" / "release" / "plexi"
|
|
47
|
+
debug = repo_root / "target" / "debug" / "plexi"
|
|
48
|
+
|
|
49
|
+
for candidate in (release, debug):
|
|
50
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
51
|
+
return str(candidate)
|
|
52
|
+
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"plexi binary not found. Build it with:\n"
|
|
55
|
+
" cargo build --release\n"
|
|
56
|
+
f"Expected at: {release}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Core render call
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def render_draw_commands(
|
|
65
|
+
commands: list[dict],
|
|
66
|
+
width: int,
|
|
67
|
+
height: int,
|
|
68
|
+
background: str = DEFAULT_BG,
|
|
69
|
+
) -> bytes:
|
|
70
|
+
"""Feed draw commands to the headless renderer, return PNG bytes.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
commands: list of PGAP draw command dicts (rect, text, line, …).
|
|
74
|
+
width: viewport width in pixels.
|
|
75
|
+
height: viewport height in pixels.
|
|
76
|
+
background: CSS hex colour string for the background fill.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Raw PNG bytes.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
RuntimeError if the binary is not found or exits nonzero.
|
|
83
|
+
"""
|
|
84
|
+
renderer = _find_renderer()
|
|
85
|
+
payload = json.dumps({
|
|
86
|
+
"viewport": {"width": width, "height": height, "background": background},
|
|
87
|
+
"draw_commands": commands,
|
|
88
|
+
})
|
|
89
|
+
try:
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
[renderer, "--render"],
|
|
92
|
+
input=payload.encode(),
|
|
93
|
+
capture_output=True,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
except OSError as e:
|
|
97
|
+
raise RuntimeError(f"Failed to spawn plexi --render ({renderer}): {e}") from e
|
|
98
|
+
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
stderr = result.stderr.decode(errors="replace").strip()
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"plexi --render exited {result.returncode}: {stderr}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return result.stdout
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# PNG decoder (stdlib only — no PIL/Pillow)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def decode_png(png_bytes: bytes) -> tuple[int, int, bytes]:
|
|
113
|
+
"""Decode an RGBA 8-bit PNG to (width, height, raw_rgba_bytes).
|
|
114
|
+
|
|
115
|
+
Pure stdlib implementation using zlib + struct. Only handles RGBA 8-bit
|
|
116
|
+
PNGs — which is exactly what tiny-skia produces.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError on an unrecognised PNG or unsupported colour type/bit depth.
|
|
120
|
+
"""
|
|
121
|
+
if not png_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
|
122
|
+
raise ValueError("Not a PNG file (bad signature)")
|
|
123
|
+
|
|
124
|
+
# Parse chunks.
|
|
125
|
+
pos = 8
|
|
126
|
+
width = height = 0
|
|
127
|
+
raw_idat = b""
|
|
128
|
+
|
|
129
|
+
while pos < len(png_bytes):
|
|
130
|
+
length = struct.unpack_from(">I", png_bytes, pos)[0]
|
|
131
|
+
chunk_type = png_bytes[pos + 4: pos + 8]
|
|
132
|
+
data = png_bytes[pos + 8: pos + 8 + length]
|
|
133
|
+
pos += 12 + length # length + type(4) + data + crc(4)
|
|
134
|
+
|
|
135
|
+
if chunk_type == b"IHDR":
|
|
136
|
+
width, height = struct.unpack_from(">II", data)
|
|
137
|
+
bit_depth = data[8]
|
|
138
|
+
colour_type = data[9]
|
|
139
|
+
if bit_depth != 8 or colour_type != 6:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Unsupported PNG colour type={colour_type} bit_depth={bit_depth}. "
|
|
142
|
+
"Only RGBA 8-bit (colour_type=6) is supported."
|
|
143
|
+
)
|
|
144
|
+
elif chunk_type == b"IDAT":
|
|
145
|
+
raw_idat += data
|
|
146
|
+
elif chunk_type == b"IEND":
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
# Decompress all IDAT chunks together.
|
|
150
|
+
decompressed = zlib.decompress(raw_idat)
|
|
151
|
+
|
|
152
|
+
# Reconstruct pixel data by reversing the PNG filter on each scanline.
|
|
153
|
+
bytes_per_pixel = 4 # RGBA
|
|
154
|
+
stride = width * bytes_per_pixel
|
|
155
|
+
raw_rgba = bytearray(stride * height)
|
|
156
|
+
|
|
157
|
+
for row in range(height):
|
|
158
|
+
filter_byte = decompressed[row * (stride + 1)]
|
|
159
|
+
line_start = row * (stride + 1) + 1
|
|
160
|
+
line = decompressed[line_start: line_start + stride]
|
|
161
|
+
|
|
162
|
+
if filter_byte == 0: # None
|
|
163
|
+
raw_rgba[row * stride: (row + 1) * stride] = line
|
|
164
|
+
elif filter_byte == 1: # Sub
|
|
165
|
+
out = bytearray(line)
|
|
166
|
+
for i in range(bytes_per_pixel, stride):
|
|
167
|
+
out[i] = (out[i] + out[i - bytes_per_pixel]) & 0xFF
|
|
168
|
+
raw_rgba[row * stride: (row + 1) * stride] = out
|
|
169
|
+
elif filter_byte == 2: # Up
|
|
170
|
+
out = bytearray(line)
|
|
171
|
+
if row > 0:
|
|
172
|
+
prev = raw_rgba[(row - 1) * stride: row * stride]
|
|
173
|
+
for i in range(stride):
|
|
174
|
+
out[i] = (out[i] + prev[i]) & 0xFF
|
|
175
|
+
raw_rgba[row * stride: (row + 1) * stride] = out
|
|
176
|
+
elif filter_byte == 3: # Average
|
|
177
|
+
out = bytearray(line)
|
|
178
|
+
prev = raw_rgba[(row - 1) * stride: row * stride] if row > 0 else bytes(stride)
|
|
179
|
+
for i in range(stride):
|
|
180
|
+
a = out[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
|
181
|
+
b = prev[i]
|
|
182
|
+
out[i] = (out[i] + (a + b) // 2) & 0xFF
|
|
183
|
+
raw_rgba[row * stride: (row + 1) * stride] = out
|
|
184
|
+
elif filter_byte == 4: # Paeth
|
|
185
|
+
out = bytearray(line)
|
|
186
|
+
prev = raw_rgba[(row - 1) * stride: row * stride] if row > 0 else bytes(stride)
|
|
187
|
+
for i in range(stride):
|
|
188
|
+
a = out[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
|
189
|
+
b = prev[i]
|
|
190
|
+
c = prev[i - bytes_per_pixel] if i >= bytes_per_pixel else 0
|
|
191
|
+
p = a + b - c
|
|
192
|
+
pa = abs(p - a)
|
|
193
|
+
pb = abs(p - b)
|
|
194
|
+
pc = abs(p - c)
|
|
195
|
+
pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
|
196
|
+
out[i] = (out[i] + pr) & 0xFF
|
|
197
|
+
raw_rgba[row * stride: (row + 1) * stride] = out
|
|
198
|
+
else:
|
|
199
|
+
raise ValueError(f"Unknown PNG filter byte {filter_byte} at row {row}")
|
|
200
|
+
|
|
201
|
+
return width, height, bytes(raw_rgba)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Pixel helpers
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def pixel_at(png_bytes: bytes, x: int, y: int) -> tuple[int, int, int, int]:
|
|
209
|
+
"""Return (r, g, b, a) of the pixel at (x, y)."""
|
|
210
|
+
width, height, raw = decode_png(png_bytes)
|
|
211
|
+
if x < 0 or x >= width or y < 0 or y >= height:
|
|
212
|
+
raise ValueError(f"Pixel ({x}, {y}) out of bounds for {width}×{height} image")
|
|
213
|
+
idx = (y * width + x) * 4
|
|
214
|
+
return raw[idx], raw[idx + 1], raw[idx + 2], raw[idx + 3]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]:
|
|
218
|
+
"""Convert a CSS hex color string to (r, g, b, a).
|
|
219
|
+
|
|
220
|
+
Supports #RGB, #RRGGBB, and #RRGGBBAA.
|
|
221
|
+
Alpha defaults to 255 when not specified.
|
|
222
|
+
"""
|
|
223
|
+
s = hex_color.lstrip("#")
|
|
224
|
+
if len(s) == 3:
|
|
225
|
+
r = int(s[0] * 2, 16)
|
|
226
|
+
g = int(s[1] * 2, 16)
|
|
227
|
+
b = int(s[2] * 2, 16)
|
|
228
|
+
return r, g, b, 255
|
|
229
|
+
elif len(s) == 6:
|
|
230
|
+
r = int(s[0:2], 16)
|
|
231
|
+
g = int(s[2:4], 16)
|
|
232
|
+
b = int(s[4:6], 16)
|
|
233
|
+
return r, g, b, 255
|
|
234
|
+
elif len(s) == 8:
|
|
235
|
+
r = int(s[0:2], 16)
|
|
236
|
+
g = int(s[2:4], 16)
|
|
237
|
+
b = int(s[4:6], 16)
|
|
238
|
+
a = int(s[6:8], 16)
|
|
239
|
+
return r, g, b, a
|
|
240
|
+
else:
|
|
241
|
+
raise ValueError(f"Unrecognised hex color: {hex_color!r}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def assert_pixel(
|
|
245
|
+
png_bytes: bytes,
|
|
246
|
+
x: int,
|
|
247
|
+
y: int,
|
|
248
|
+
expected: str | tuple[int, int, int, int],
|
|
249
|
+
tolerance: int = 4,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Assert the pixel at (x, y) matches the expected color within per-channel tolerance.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
png_bytes: raw PNG bytes from render_draw_commands.
|
|
255
|
+
x, y: pixel coordinates.
|
|
256
|
+
expected: CSS hex string (e.g. "#ff0000") or (r, g, b, a) tuple.
|
|
257
|
+
tolerance: per-channel absolute tolerance (default 4).
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
AssertionError with a diagnostic message on mismatch.
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(expected, str):
|
|
263
|
+
exp = hex_to_rgba(expected)
|
|
264
|
+
else:
|
|
265
|
+
exp = expected
|
|
266
|
+
|
|
267
|
+
got = pixel_at(png_bytes, x, y)
|
|
268
|
+
channels = ("r", "g", "b", "a")
|
|
269
|
+
mismatches = []
|
|
270
|
+
for ch, g, e in zip(channels, got, exp):
|
|
271
|
+
if abs(g - e) > tolerance:
|
|
272
|
+
mismatches.append(f" {ch}: got {g}, expected {e} (diff {abs(g - e)} > tolerance {tolerance})")
|
|
273
|
+
|
|
274
|
+
if mismatches:
|
|
275
|
+
exp_hex = "#{:02x}{:02x}{:02x}{:02x}".format(*exp)
|
|
276
|
+
got_hex = "#{:02x}{:02x}{:02x}{:02x}".format(*got)
|
|
277
|
+
raise AssertionError(
|
|
278
|
+
f"Pixel ({x}, {y}) mismatch:\n"
|
|
279
|
+
f" expected {exp_hex}, got {got_hex}\n"
|
|
280
|
+
+ "\n".join(mismatches)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Snapshot file helper
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def save_snapshot(png_bytes: bytes, path: str | Path) -> None:
|
|
289
|
+
"""Write PNG bytes to path. Creates parent directories as needed."""
|
|
290
|
+
p = Path(path)
|
|
291
|
+
try:
|
|
292
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
p.write_bytes(png_bytes)
|
|
294
|
+
except OSError as e:
|
|
295
|
+
raise RuntimeError(f"Failed to save snapshot to {p}: {e}") from e
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# AppHarness — headless Python app subprocess runner (issue #865)
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class AppHarness:
|
|
304
|
+
"""Headless runner for Plexi Python apps.
|
|
305
|
+
|
|
306
|
+
Spawns the app script as a subprocess, communicates over stdin/stdout
|
|
307
|
+
using the PGAP v3 protocol. No display or running Plexi host required.
|
|
308
|
+
|
|
309
|
+
Usage::
|
|
310
|
+
|
|
311
|
+
with AppHarness("my_app.py", width=400, height=300) as h:
|
|
312
|
+
cmds = h.run(1) # step one render frame
|
|
313
|
+
h.key("enter") # inject a key event
|
|
314
|
+
cmds = h.run(1) # render again to see effects
|
|
315
|
+
h.assert_pixel(10, 10, "#1e1e2e") # pixel assertion (needs plexi binary)
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __init__(self, app_path: "str | Path", width: int = 800, height: int = 600,
|
|
319
|
+
timeout: float = 5.0) -> None:
|
|
320
|
+
self._width = width
|
|
321
|
+
self._height = height
|
|
322
|
+
self._timeout = timeout
|
|
323
|
+
self._draw_commands: "list[dict]" = []
|
|
324
|
+
self._frame_id = 0
|
|
325
|
+
self._stdout_q: "queue.Queue[str | None]" = queue.Queue()
|
|
326
|
+
|
|
327
|
+
import os
|
|
328
|
+
import subprocess as _subprocess
|
|
329
|
+
env = dict(os.environ)
|
|
330
|
+
env["PYTHONPATH"] = os.pathsep.join(sys.path)
|
|
331
|
+
|
|
332
|
+
self._proc = _subprocess.Popen(
|
|
333
|
+
[sys.executable, str(app_path)],
|
|
334
|
+
stdin=_subprocess.PIPE,
|
|
335
|
+
stdout=_subprocess.PIPE,
|
|
336
|
+
stderr=_subprocess.PIPE,
|
|
337
|
+
text=True,
|
|
338
|
+
env=env,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
self._reader = threading.Thread(target=self._read_stdout, daemon=True)
|
|
342
|
+
self._reader.start()
|
|
343
|
+
|
|
344
|
+
# Handshake
|
|
345
|
+
self._send({"type": "init", "protocol": "pgap/3", "app_id": "test-harness",
|
|
346
|
+
"workspace_root": "/tmp", "capabilities": [], "feature_flags": []})
|
|
347
|
+
ev = self._wait_for(lambda e: e.get("type") == "ready")
|
|
348
|
+
if ev is None:
|
|
349
|
+
stderr = self._proc.stderr.read() if self._proc.stderr else ""
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
f"AppHarness: app did not send 'ready' within {timeout}s. "
|
|
352
|
+
f"stderr: {stderr[:500]!r}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _read_stdout(self) -> None:
|
|
356
|
+
assert self._proc.stdout is not None
|
|
357
|
+
for line in self._proc.stdout:
|
|
358
|
+
line = line.strip()
|
|
359
|
+
if line:
|
|
360
|
+
self._stdout_q.put(line)
|
|
361
|
+
self._stdout_q.put(None) # EOF sentinel
|
|
362
|
+
|
|
363
|
+
def _next_event(self, timeout: float) -> "dict | None":
|
|
364
|
+
try:
|
|
365
|
+
line = self._stdout_q.get(timeout=timeout)
|
|
366
|
+
except queue.Empty:
|
|
367
|
+
return None
|
|
368
|
+
if line is None:
|
|
369
|
+
return None
|
|
370
|
+
try:
|
|
371
|
+
return json.loads(line)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
def _wait_for(self, predicate, timeout=None) -> "dict | None":
|
|
376
|
+
deadline = time.monotonic() + (timeout if timeout is not None else self._timeout)
|
|
377
|
+
while time.monotonic() < deadline:
|
|
378
|
+
remaining = deadline - time.monotonic()
|
|
379
|
+
ev = self._next_event(timeout=max(0.05, remaining))
|
|
380
|
+
if ev is not None and predicate(ev):
|
|
381
|
+
return ev
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
def _send(self, event: dict) -> None:
|
|
385
|
+
assert self._proc.stdin is not None
|
|
386
|
+
self._proc.stdin.write(json.dumps(event) + "\n")
|
|
387
|
+
self._proc.stdin.flush()
|
|
388
|
+
|
|
389
|
+
def run(self, n_frames: int = 1) -> "list[dict]":
|
|
390
|
+
"""Step N render frames. Returns draw commands from the last frame."""
|
|
391
|
+
cmds: "list[dict]" = []
|
|
392
|
+
for _ in range(n_frames):
|
|
393
|
+
self._frame_id += 1
|
|
394
|
+
self._send({
|
|
395
|
+
"type": "render",
|
|
396
|
+
"frame_id": self._frame_id,
|
|
397
|
+
"rect": {"x": 0.0, "y": 0.0, "w": float(self._width), "h": float(self._height)},
|
|
398
|
+
})
|
|
399
|
+
cmds = []
|
|
400
|
+
deadline = time.monotonic() + self._timeout
|
|
401
|
+
while time.monotonic() < deadline:
|
|
402
|
+
remaining = deadline - time.monotonic()
|
|
403
|
+
ev = self._next_event(timeout=max(0.05, remaining))
|
|
404
|
+
if ev is None:
|
|
405
|
+
continue
|
|
406
|
+
if ev.get("type") == "frame_done":
|
|
407
|
+
break
|
|
408
|
+
cmds.append(ev)
|
|
409
|
+
self._draw_commands = cmds
|
|
410
|
+
return cmds
|
|
411
|
+
|
|
412
|
+
def key(self, key: str, modifiers: "dict | None" = None) -> None:
|
|
413
|
+
"""Inject a synthetic key event."""
|
|
414
|
+
self._send({"type": "key", "key": key, "modifiers": modifiers or {}})
|
|
415
|
+
|
|
416
|
+
def text_submit(self, id: str, value: str) -> None:
|
|
417
|
+
"""Inject a synthetic text_submitted event (user pressed Enter in a TextInput)."""
|
|
418
|
+
self._send({"type": "text_submitted", "id": id, "value": value})
|
|
419
|
+
|
|
420
|
+
def screenshot(self) -> bytes:
|
|
421
|
+
"""Render current draw commands to PNG. Requires the plexi binary."""
|
|
422
|
+
return render_draw_commands(self._draw_commands, self._width, self._height)
|
|
423
|
+
|
|
424
|
+
def assert_pixel(self, x: int, y: int,
|
|
425
|
+
expected: "str | tuple[int, int, int, int]",
|
|
426
|
+
tolerance: int = 4) -> None:
|
|
427
|
+
"""Assert pixel color at (x, y) against the last rendered frame."""
|
|
428
|
+
assert_pixel(self.screenshot(), x, y, expected, tolerance)
|
|
429
|
+
|
|
430
|
+
def save_snapshot(self, path: "str | Path") -> None:
|
|
431
|
+
"""Save the last rendered frame as a PNG file."""
|
|
432
|
+
save_snapshot(self.screenshot(), path)
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
"""Shut down the app subprocess cleanly."""
|
|
436
|
+
import subprocess as _subprocess
|
|
437
|
+
try:
|
|
438
|
+
if self._proc.stdin:
|
|
439
|
+
self._proc.stdin.close()
|
|
440
|
+
except OSError:
|
|
441
|
+
pass
|
|
442
|
+
try:
|
|
443
|
+
self._proc.wait(timeout=5.0)
|
|
444
|
+
except _subprocess.TimeoutExpired:
|
|
445
|
+
self._proc.kill()
|
|
446
|
+
|
|
447
|
+
def __enter__(self) -> "AppHarness":
|
|
448
|
+
return self
|
|
449
|
+
|
|
450
|
+
def __exit__(self, *_: object) -> None:
|
|
451
|
+
self.close()
|