wafer-core 0.1.38__py3-none-any.whl → 0.1.39__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.
- wafer_core/lib/trace_compare/fusion_analyzer.py +2 -0
- wafer_core/rollouts/_logging/__init__.py +5 -1
- wafer_core/rollouts/_logging/logging_config.py +95 -3
- wafer_core/rollouts/_logging/sample_handler.py +66 -0
- wafer_core/rollouts/_pytui/__init__.py +114 -0
- wafer_core/rollouts/_pytui/app.py +809 -0
- wafer_core/rollouts/_pytui/console.py +291 -0
- wafer_core/rollouts/_pytui/renderer.py +210 -0
- wafer_core/rollouts/_pytui/spinner.py +73 -0
- wafer_core/rollouts/_pytui/terminal.py +489 -0
- wafer_core/rollouts/_pytui/text.py +470 -0
- wafer_core/rollouts/_pytui/theme.py +241 -0
- wafer_core/rollouts/evaluation.py +142 -177
- wafer_core/rollouts/progress_app.py +395 -0
- wafer_core/rollouts/tui/DESIGN.md +251 -115
- wafer_core/rollouts/tui/monitor.py +64 -20
- wafer_core/tools/compile/__init__.py +30 -0
- wafer_core/tools/compile/compiler.py +314 -0
- wafer_core/tools/compile/modal_compile.py +359 -0
- wafer_core/tools/compile/tests/__init__.py +1 -0
- wafer_core/tools/compile/tests/test_compiler.py +675 -0
- wafer_core/tools/compile/tests/test_data/utils.cuh +10 -0
- wafer_core/tools/compile/tests/test_data/vector_add.cu +7 -0
- wafer_core/tools/compile/tests/test_data/with_header.cu +9 -0
- wafer_core/tools/compile/tests/test_modal_integration.py +326 -0
- wafer_core/tools/compile/types.py +117 -0
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/METADATA +1 -1
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/RECORD +29 -12
- wafer_core/rollouts/events.py +0 -240
- wafer_core/rollouts/progress_display.py +0 -476
- wafer_core/utils/event_streaming.py +0 -63
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""Elm-style TUI application runtime.
|
|
2
|
+
|
|
3
|
+
Provides the App class that runs the Model-Update-View loop,
|
|
4
|
+
plus Cmd and Sub types for side effects and subscriptions.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from pytui import App, Cmd, Sub, KeyPress
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Model:
|
|
12
|
+
count: int = 0
|
|
13
|
+
|
|
14
|
+
def update(model: Model, msg: object) -> tuple[Model, Cmd]:
|
|
15
|
+
match msg:
|
|
16
|
+
case KeyPress(key="q"):
|
|
17
|
+
return model, Cmd.quit()
|
|
18
|
+
case KeyPress(key="j"):
|
|
19
|
+
return replace(model, count=model.count + 1), Cmd.none()
|
|
20
|
+
return model, Cmd.none()
|
|
21
|
+
|
|
22
|
+
def view(model: Model, width: int, height: int) -> list[str]:
|
|
23
|
+
return [f"Count: {model.count}", "", "j: increment q: quit"]
|
|
24
|
+
|
|
25
|
+
App(init=(Model(), Cmd.none()), update=update, view=view).run()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import queue
|
|
33
|
+
import select
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from .renderer import RenderState, diff_render
|
|
43
|
+
from .terminal import Terminal
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Debug logging (file-based, not stderr)
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_DEBUG_LOG: Path | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _log(event: str, **data: Any) -> None:
|
|
53
|
+
"""Append a debug event to the log file (if enabled)."""
|
|
54
|
+
if _DEBUG_LOG is None:
|
|
55
|
+
return
|
|
56
|
+
entry = {
|
|
57
|
+
"ts": datetime.now().isoformat(),
|
|
58
|
+
"event": event,
|
|
59
|
+
**data,
|
|
60
|
+
}
|
|
61
|
+
with open(_DEBUG_LOG, "a") as f:
|
|
62
|
+
f.write(json.dumps(entry) + "\n")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Built-in messages (sent by runtime)
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class KeyPress:
|
|
72
|
+
"""Keyboard input. key is the raw string (e.g. "j", "\x1b[A")."""
|
|
73
|
+
|
|
74
|
+
key: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Resize:
|
|
79
|
+
"""Terminal was resized."""
|
|
80
|
+
|
|
81
|
+
width: int
|
|
82
|
+
height: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class MouseEvent:
|
|
87
|
+
"""Mouse event (button press, release, wheel scroll).
|
|
88
|
+
|
|
89
|
+
Button values:
|
|
90
|
+
0 = left click
|
|
91
|
+
1 = middle click
|
|
92
|
+
2 = right click
|
|
93
|
+
64 = wheel up
|
|
94
|
+
65 = wheel down
|
|
95
|
+
66 = wheel left
|
|
96
|
+
67 = wheel right
|
|
97
|
+
|
|
98
|
+
Action values:
|
|
99
|
+
"press" = button pressed
|
|
100
|
+
"release" = button released
|
|
101
|
+
"motion" = mouse moved while button held
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
button: int
|
|
105
|
+
x: int # 1-indexed column
|
|
106
|
+
y: int # 1-indexed row
|
|
107
|
+
action: str # "press", "release", or "motion"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_wheel_up(self) -> bool:
|
|
111
|
+
return self.button == 64
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_wheel_down(self) -> bool:
|
|
115
|
+
return self.button == 65
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PasteEvent:
|
|
120
|
+
"""Bracketed paste content.
|
|
121
|
+
|
|
122
|
+
When bracketed paste mode is enabled, pasted text is wrapped in
|
|
123
|
+
escape sequences so it can be distinguished from typed input.
|
|
124
|
+
This prevents pasted text from triggering keybindings.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
text: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class FocusEvent:
|
|
132
|
+
"""Terminal focus change.
|
|
133
|
+
|
|
134
|
+
Sent when the terminal window gains or loses focus.
|
|
135
|
+
Requires focus reporting to be enabled.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
focused: bool # True = gained focus, False = lost focus
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_mouse_sgr(seq: str) -> MouseEvent | None:
|
|
142
|
+
"""Parse SGR mouse sequence: ESC [ < Btn ; X ; Y M/m
|
|
143
|
+
|
|
144
|
+
Returns MouseEvent or None if not a valid mouse sequence.
|
|
145
|
+
"""
|
|
146
|
+
import re
|
|
147
|
+
|
|
148
|
+
# SGR format: \x1b[<btn;x;y[Mm]
|
|
149
|
+
match = re.match(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])", seq)
|
|
150
|
+
if not match:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
btn = int(match.group(1))
|
|
154
|
+
x = int(match.group(2))
|
|
155
|
+
y = int(match.group(3))
|
|
156
|
+
release = match.group(4) == "m"
|
|
157
|
+
|
|
158
|
+
# Decode button and modifiers
|
|
159
|
+
# Bits 0-1: button (0=left, 1=middle, 2=right)
|
|
160
|
+
# Bit 5: motion
|
|
161
|
+
# Bits 6-7: wheel (64=up, 65=down)
|
|
162
|
+
action = "release" if release else "press"
|
|
163
|
+
if btn & 32:
|
|
164
|
+
action = "motion"
|
|
165
|
+
|
|
166
|
+
return MouseEvent(button=btn & ~32, x=x, y=y, action=action)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_focus(seq: str) -> FocusEvent | None:
|
|
170
|
+
"""Parse focus event: ESC [ I (focus) or ESC [ O (blur)."""
|
|
171
|
+
if seq == "\x1b[I":
|
|
172
|
+
return FocusEvent(focused=True)
|
|
173
|
+
if seq == "\x1b[O":
|
|
174
|
+
return FocusEvent(focused=False)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Bracketed paste markers
|
|
179
|
+
_PASTE_START = "\x1b[200~"
|
|
180
|
+
_PASTE_END = "\x1b[201~"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Cmd: side effect descriptors
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Cmd:
|
|
190
|
+
"""Side effect descriptor. Created by update(), executed by runtime.
|
|
191
|
+
|
|
192
|
+
Users should only create Cmd values via the static methods.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
_kind: str = "none"
|
|
196
|
+
_data: Any = None
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def none() -> Cmd:
|
|
200
|
+
"""No side effect."""
|
|
201
|
+
return Cmd()
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def quit() -> Cmd:
|
|
205
|
+
"""Exit the application."""
|
|
206
|
+
return Cmd(_kind="quit")
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def batch(*cmds: Cmd) -> Cmd:
|
|
210
|
+
"""Run multiple commands."""
|
|
211
|
+
# Flatten: skip nones, unwrap single
|
|
212
|
+
real = [c for c in cmds if c._kind != "none"]
|
|
213
|
+
if not real:
|
|
214
|
+
return Cmd.none()
|
|
215
|
+
if len(real) == 1:
|
|
216
|
+
return real[0]
|
|
217
|
+
return Cmd(_kind="batch", _data=tuple(real))
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def task(fn: Callable[[], object]) -> Cmd:
|
|
221
|
+
"""Run fn() in a background thread. Result is sent as a message.
|
|
222
|
+
|
|
223
|
+
fn must be safe to call from a thread. The return value becomes
|
|
224
|
+
the next message dispatched to update().
|
|
225
|
+
"""
|
|
226
|
+
return Cmd(_kind="task", _data=fn)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Sub: subscription descriptors
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class Sub:
|
|
236
|
+
"""Subscription descriptor. Long-running sources of messages.
|
|
237
|
+
|
|
238
|
+
Users should only create Sub values via the static methods.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
_kind: str = "none"
|
|
242
|
+
_data: Any = None
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def none() -> Sub:
|
|
246
|
+
"""No subscription."""
|
|
247
|
+
return Sub()
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def every(interval_sec: float, msg_fn: Callable[[], object]) -> Sub:
|
|
251
|
+
"""Call msg_fn() every interval_sec seconds, send result as message."""
|
|
252
|
+
return Sub(_kind="every", _data=(interval_sec, msg_fn))
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def file_tail(path: str, msg_fn: Callable[[str], object]) -> Sub:
|
|
256
|
+
"""Tail a file. msg_fn(line) called for each new line appended."""
|
|
257
|
+
return Sub(_kind="file_tail", _data=(path, msg_fn))
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def batch(*subs: Sub) -> Sub:
|
|
261
|
+
"""Combine multiple subscriptions."""
|
|
262
|
+
real = [s for s in subs if s._kind != "none"]
|
|
263
|
+
if not real:
|
|
264
|
+
return Sub.none()
|
|
265
|
+
if len(real) == 1:
|
|
266
|
+
return real[0]
|
|
267
|
+
return Sub(_kind="batch", _data=tuple(real))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Internal: subscription identity for lifecycle management
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _sub_key(sub: Sub) -> tuple:
|
|
276
|
+
"""Identity key for a subscription (for start/stop diffing)."""
|
|
277
|
+
def _stable_callable_key(fn: Callable[..., object]) -> tuple:
|
|
278
|
+
"""Return a stable, hashable identity for callables.
|
|
279
|
+
|
|
280
|
+
Subscriptions are recomputed on every update(). Users often write
|
|
281
|
+
`lambda ...` inline inside subscriptions(), which creates a new
|
|
282
|
+
function object each call. Keying on `id(fn)` causes subscriptions
|
|
283
|
+
to churn (stop/restart) on every message, which breaks file tailing
|
|
284
|
+
and floods the UI with duplicate log lines.
|
|
285
|
+
|
|
286
|
+
Prefer code-location-based keys when available; fall back to object
|
|
287
|
+
identity for non-function callables.
|
|
288
|
+
"""
|
|
289
|
+
# Bound method: stabilize on (underlying func location, instance id)
|
|
290
|
+
func = getattr(fn, "__func__", None)
|
|
291
|
+
self_obj = getattr(fn, "__self__", None)
|
|
292
|
+
if func is not None and hasattr(func, "__code__"):
|
|
293
|
+
code = func.__code__
|
|
294
|
+
return ("method", code.co_filename, code.co_firstlineno, code.co_name, id(self_obj))
|
|
295
|
+
|
|
296
|
+
# Plain function / lambda: stabilize on code location (and closure contents)
|
|
297
|
+
code = getattr(fn, "__code__", None)
|
|
298
|
+
if code is not None:
|
|
299
|
+
closure = getattr(fn, "__closure__", None) or ()
|
|
300
|
+
closure_key = []
|
|
301
|
+
for cell in closure:
|
|
302
|
+
try:
|
|
303
|
+
val = cell.cell_contents
|
|
304
|
+
except ValueError:
|
|
305
|
+
val = None
|
|
306
|
+
if val is None or isinstance(val, (bool, int, float, str)):
|
|
307
|
+
closure_key.append(("lit", val))
|
|
308
|
+
else:
|
|
309
|
+
closure_key.append(("id", id(val)))
|
|
310
|
+
return (
|
|
311
|
+
"func",
|
|
312
|
+
code.co_filename,
|
|
313
|
+
code.co_firstlineno,
|
|
314
|
+
code.co_name,
|
|
315
|
+
tuple(closure_key),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return ("id", id(fn))
|
|
319
|
+
|
|
320
|
+
if sub._kind == "every":
|
|
321
|
+
interval, fn = sub._data
|
|
322
|
+
return ("every", interval, _stable_callable_key(fn))
|
|
323
|
+
elif sub._kind == "file_tail":
|
|
324
|
+
path, fn = sub._data
|
|
325
|
+
return ("file_tail", path, _stable_callable_key(fn))
|
|
326
|
+
elif sub._kind == "batch":
|
|
327
|
+
return ("batch", tuple(_sub_key(s) for s in sub._data))
|
|
328
|
+
return ("none",)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _flatten_subs(sub: Sub) -> list[Sub]:
|
|
332
|
+
"""Flatten batch subs into a flat list of leaf subs."""
|
|
333
|
+
if sub._kind == "batch":
|
|
334
|
+
result = []
|
|
335
|
+
for s in sub._data:
|
|
336
|
+
result.extend(_flatten_subs(s))
|
|
337
|
+
return result
|
|
338
|
+
elif sub._kind == "none":
|
|
339
|
+
return []
|
|
340
|
+
return [sub]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# Internal: subscription runners (threads)
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass
|
|
349
|
+
class _SubRunner:
|
|
350
|
+
"""Running subscription thread + stop event."""
|
|
351
|
+
|
|
352
|
+
key: tuple
|
|
353
|
+
stop: threading.Event
|
|
354
|
+
thread: threading.Thread
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _WakeQueue:
|
|
358
|
+
"""Queue that writes a byte to a wakeup pipe on put().
|
|
359
|
+
|
|
360
|
+
Background threads enqueue messages here. The main loop select()s
|
|
361
|
+
on the wakeup fd alongside stdin, so it wakes immediately instead
|
|
362
|
+
of polling on a timer.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
__slots__ = ("_q", "_wakeup_w")
|
|
366
|
+
|
|
367
|
+
def __init__(self, wakeup_w: int) -> None:
|
|
368
|
+
self._q: queue.Queue = queue.Queue()
|
|
369
|
+
self._wakeup_w = wakeup_w
|
|
370
|
+
|
|
371
|
+
def put(self, msg: object) -> None:
|
|
372
|
+
self._q.put(msg)
|
|
373
|
+
try:
|
|
374
|
+
os.write(self._wakeup_w, b"\x00")
|
|
375
|
+
except OSError:
|
|
376
|
+
pass # Pipe full or closed — main loop will drain queue anyway
|
|
377
|
+
|
|
378
|
+
def get_nowait(self) -> object:
|
|
379
|
+
return self._q.get_nowait()
|
|
380
|
+
|
|
381
|
+
def empty(self) -> bool:
|
|
382
|
+
return self._q.empty()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _run_every(
|
|
386
|
+
interval: float,
|
|
387
|
+
msg_fn: Callable[[], object],
|
|
388
|
+
msg_queue: _WakeQueue,
|
|
389
|
+
stop: threading.Event,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Thread target for Sub.every."""
|
|
392
|
+
while not stop.is_set():
|
|
393
|
+
stop.wait(interval)
|
|
394
|
+
if not stop.is_set():
|
|
395
|
+
msg_queue.put(msg_fn())
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _run_file_tail(
|
|
399
|
+
path: str,
|
|
400
|
+
msg_fn: Callable[[str], object],
|
|
401
|
+
msg_queue: _WakeQueue,
|
|
402
|
+
stop: threading.Event,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Thread target for Sub.file_tail.
|
|
405
|
+
|
|
406
|
+
Waits for file to exist, reads existing content, then tails for new lines.
|
|
407
|
+
"""
|
|
408
|
+
p = Path(path)
|
|
409
|
+
|
|
410
|
+
# Wait for file to exist
|
|
411
|
+
while not p.exists() and not stop.is_set():
|
|
412
|
+
stop.wait(0.5)
|
|
413
|
+
|
|
414
|
+
if stop.is_set():
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# Use errors="replace" so a single bad byte can't kill the tail thread.
|
|
418
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
419
|
+
# Read from beginning (existing content + new lines).
|
|
420
|
+
# Keep a buffer so we don't emit partial lines when the writer flushes
|
|
421
|
+
# without a trailing newline.
|
|
422
|
+
pending = ""
|
|
423
|
+
while not stop.is_set():
|
|
424
|
+
chunk = f.readline()
|
|
425
|
+
if not chunk:
|
|
426
|
+
stop.wait(0.1)
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
if chunk.endswith("\n"):
|
|
430
|
+
full = pending + chunk
|
|
431
|
+
pending = ""
|
|
432
|
+
stripped = full.rstrip("\n")
|
|
433
|
+
if stripped: # Skip blank lines
|
|
434
|
+
msg_queue.put(msg_fn(stripped))
|
|
435
|
+
else:
|
|
436
|
+
pending += chunk
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
# App: the runtime
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
# Type aliases for user-provided functions
|
|
444
|
+
UpdateFn = Callable[[Any, object], tuple[Any, Cmd]]
|
|
445
|
+
ViewFn = Callable[[Any, int, int], list[str]]
|
|
446
|
+
SubsFn = Callable[[Any], Sub]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class App:
|
|
450
|
+
"""Elm-style TUI application runtime.
|
|
451
|
+
|
|
452
|
+
Owns the terminal, runs the main loop, executes commands,
|
|
453
|
+
manages subscriptions, and calls view.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
init: (initial_model, initial_cmd) tuple.
|
|
457
|
+
update: Pure function (model, msg) -> (model, cmd).
|
|
458
|
+
view: Pure function (model, width, height) -> list[str].
|
|
459
|
+
subscriptions: Optional function (model) -> Sub.
|
|
460
|
+
alternate_screen: Use alternate screen buffer (monitor-style apps).
|
|
461
|
+
bracketed_paste: Enable bracketed paste mode (editor-style apps).
|
|
462
|
+
mouse: Enable mouse tracking (wheel scroll, clicks).
|
|
463
|
+
fps: Deprecated — kept for backwards compat. Sets min_frame_interval
|
|
464
|
+
to 1/fps. Prefer min_frame_interval directly.
|
|
465
|
+
min_frame_interval: Minimum seconds between renders. Batches rapid
|
|
466
|
+
events (e.g. file tail spew) into fewer frames. Default 0.016
|
|
467
|
+
(~60fps cap). Set to 0 for unlimited.
|
|
468
|
+
debug_log: Path to debug log file. If set, logs subscriptions, messages,
|
|
469
|
+
and app lifecycle events to this file as JSONL.
|
|
470
|
+
debug_fn: Optional callback (model, width, height, frame_count) -> None.
|
|
471
|
+
Called every debug_frame_interval rendered frames. For dumping
|
|
472
|
+
layout snapshots, model state, etc. to a debug log file.
|
|
473
|
+
debug_frame_interval: How often to call debug_fn (every N frames).
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def __init__(
|
|
477
|
+
self,
|
|
478
|
+
*,
|
|
479
|
+
init: tuple[Any, Cmd],
|
|
480
|
+
update: UpdateFn,
|
|
481
|
+
view: ViewFn,
|
|
482
|
+
subscriptions: SubsFn | None = None,
|
|
483
|
+
alternate_screen: bool = True,
|
|
484
|
+
bracketed_paste: bool = False,
|
|
485
|
+
mouse: bool = False,
|
|
486
|
+
fps: int | None = None,
|
|
487
|
+
min_frame_interval: float = 0.016,
|
|
488
|
+
debug_log: str | Path | None = None,
|
|
489
|
+
debug_fn: Callable[[Any, int, int, int], None] | None = None,
|
|
490
|
+
debug_frame_interval: int = 100,
|
|
491
|
+
) -> None:
|
|
492
|
+
self._init = init
|
|
493
|
+
self._update_fn = update
|
|
494
|
+
self._view_fn = view
|
|
495
|
+
self._subs_fn = subscriptions
|
|
496
|
+
self._alternate_screen = alternate_screen
|
|
497
|
+
self._bracketed_paste = bracketed_paste
|
|
498
|
+
self._mouse = mouse
|
|
499
|
+
self._min_frame_interval = 1.0 / fps if fps is not None else min_frame_interval
|
|
500
|
+
self._debug_fn = debug_fn
|
|
501
|
+
self._debug_frame_interval = debug_frame_interval
|
|
502
|
+
|
|
503
|
+
self._model: Any = None
|
|
504
|
+
self._running = False
|
|
505
|
+
self._frame_count: int = 0
|
|
506
|
+
|
|
507
|
+
# Wakeup pipe: background threads write a byte here to wake select()
|
|
508
|
+
self._wakeup_r, self._wakeup_w = os.pipe()
|
|
509
|
+
os.set_blocking(self._wakeup_r, False)
|
|
510
|
+
os.set_blocking(self._wakeup_w, False)
|
|
511
|
+
|
|
512
|
+
self._msg_queue = _WakeQueue(self._wakeup_w)
|
|
513
|
+
self._terminal: Terminal | None = None
|
|
514
|
+
self._paste_buffer: str | None = None # Collecting paste content
|
|
515
|
+
|
|
516
|
+
# Set up debug logging
|
|
517
|
+
global _DEBUG_LOG
|
|
518
|
+
if debug_log is not None:
|
|
519
|
+
_DEBUG_LOG = Path(debug_log)
|
|
520
|
+
# Truncate on startup
|
|
521
|
+
_DEBUG_LOG.write_text("")
|
|
522
|
+
os.chmod(_DEBUG_LOG, 0o644)
|
|
523
|
+
_log(
|
|
524
|
+
"app_init",
|
|
525
|
+
min_frame_interval=self._min_frame_interval,
|
|
526
|
+
alternate_screen=alternate_screen,
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
_DEBUG_LOG = None
|
|
530
|
+
self._render_state = RenderState(log_fn=_log if _DEBUG_LOG is not None else None)
|
|
531
|
+
self._active_subs: dict[tuple, _SubRunner] = {}
|
|
532
|
+
|
|
533
|
+
def send(self, msg: object) -> None:
|
|
534
|
+
"""Send a message from outside the update loop (thread-safe).
|
|
535
|
+
|
|
536
|
+
Wakes the main loop immediately via the wakeup pipe.
|
|
537
|
+
"""
|
|
538
|
+
self._msg_queue.put(msg)
|
|
539
|
+
|
|
540
|
+
def _drain_wakeup(self) -> None:
|
|
541
|
+
"""Drain all bytes from the wakeup pipe (non-blocking)."""
|
|
542
|
+
try:
|
|
543
|
+
while os.read(self._wakeup_r, 4096):
|
|
544
|
+
pass
|
|
545
|
+
except (BlockingIOError, OSError):
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
def run(self) -> None:
|
|
549
|
+
"""Run the application. Blocks until quit.
|
|
550
|
+
|
|
551
|
+
Event-driven: blocks on select() until stdin has input or a
|
|
552
|
+
background thread enqueues a message (via wakeup pipe).
|
|
553
|
+
Zero CPU when idle.
|
|
554
|
+
"""
|
|
555
|
+
terminal = Terminal(
|
|
556
|
+
alternate_screen=self._alternate_screen,
|
|
557
|
+
bracketed_paste=self._bracketed_paste,
|
|
558
|
+
mouse=self._mouse,
|
|
559
|
+
)
|
|
560
|
+
self._terminal = terminal
|
|
561
|
+
|
|
562
|
+
# We don't use the on_input callback — we select() on the tty fd.
|
|
563
|
+
# Resize writes to the wakeup pipe so select() wakes up.
|
|
564
|
+
def on_input(data: str) -> None:
|
|
565
|
+
pass # Unused, we read from tty fd directly
|
|
566
|
+
|
|
567
|
+
def on_resize() -> None:
|
|
568
|
+
self._msg_queue.put(
|
|
569
|
+
Resize(
|
|
570
|
+
width=terminal.columns,
|
|
571
|
+
height=terminal.rows,
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
terminal.start(on_input=on_input, on_resize=on_resize)
|
|
576
|
+
terminal.hide_cursor()
|
|
577
|
+
|
|
578
|
+
tty_fd = terminal._tty_fd
|
|
579
|
+
wakeup_r = self._wakeup_r
|
|
580
|
+
wait_fds = [wakeup_r]
|
|
581
|
+
if tty_fd is not None:
|
|
582
|
+
wait_fds.append(tty_fd)
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
# Initialize
|
|
586
|
+
self._model, init_cmd = self._init
|
|
587
|
+
self._running = True
|
|
588
|
+
self._execute_cmd(init_cmd)
|
|
589
|
+
|
|
590
|
+
# Initial subscription setup
|
|
591
|
+
if self._subs_fn:
|
|
592
|
+
self._sync_subs(self._subs_fn(self._model))
|
|
593
|
+
|
|
594
|
+
# Initial render
|
|
595
|
+
self._render()
|
|
596
|
+
|
|
597
|
+
last_render = 0.0 # Force immediate first-event render
|
|
598
|
+
min_interval = self._min_frame_interval
|
|
599
|
+
pending_dirty = False
|
|
600
|
+
msgs_since_render = 0
|
|
601
|
+
msg_types_since_render: dict[str, int] = {}
|
|
602
|
+
|
|
603
|
+
# Main loop — event-driven, blocks on select()
|
|
604
|
+
while self._running:
|
|
605
|
+
# Calculate select timeout:
|
|
606
|
+
# - If we have pending dirty state from rapid events,
|
|
607
|
+
# wait only until min_interval elapses, then render.
|
|
608
|
+
# - Otherwise block indefinitely until something happens.
|
|
609
|
+
if pending_dirty:
|
|
610
|
+
remaining = min_interval - (time.monotonic() - last_render)
|
|
611
|
+
timeout = max(0.0, remaining)
|
|
612
|
+
else:
|
|
613
|
+
timeout = None # Block indefinitely
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
select.select(wait_fds, [], [], timeout)
|
|
617
|
+
except (InterruptedError, OSError):
|
|
618
|
+
# SIGWINCH can interrupt select — that's fine,
|
|
619
|
+
# the resize handler already enqueued a message.
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
# Drain wakeup pipe
|
|
623
|
+
self._drain_wakeup()
|
|
624
|
+
|
|
625
|
+
dirty = False
|
|
626
|
+
|
|
627
|
+
# 1. Drain all available keyboard/mouse input
|
|
628
|
+
while True:
|
|
629
|
+
key = terminal.read_input()
|
|
630
|
+
if key is None:
|
|
631
|
+
break
|
|
632
|
+
dirty = True
|
|
633
|
+
msgs_since_render += 1
|
|
634
|
+
msg = self._parse_input(key)
|
|
635
|
+
if msg is not None:
|
|
636
|
+
t = type(msg).__name__
|
|
637
|
+
msg_types_since_render[t] = msg_types_since_render.get(t, 0) + 1
|
|
638
|
+
self._dispatch(msg)
|
|
639
|
+
|
|
640
|
+
# 2. Drain message queue (from Cmd.task threads, subs, resize)
|
|
641
|
+
while not self._msg_queue.empty():
|
|
642
|
+
dirty = True
|
|
643
|
+
try:
|
|
644
|
+
msg = self._msg_queue.get_nowait()
|
|
645
|
+
except queue.Empty:
|
|
646
|
+
break
|
|
647
|
+
msgs_since_render += 1
|
|
648
|
+
t = type(msg).__name__
|
|
649
|
+
msg_types_since_render[t] = msg_types_since_render.get(t, 0) + 1
|
|
650
|
+
self._dispatch(msg)
|
|
651
|
+
|
|
652
|
+
# 3. Re-sync subscriptions if model changed this iteration
|
|
653
|
+
if dirty and self._subs_fn:
|
|
654
|
+
self._sync_subs(self._subs_fn(self._model))
|
|
655
|
+
|
|
656
|
+
if dirty:
|
|
657
|
+
pending_dirty = True
|
|
658
|
+
|
|
659
|
+
# 4. Render if dirty and enough time has passed since last render
|
|
660
|
+
now = time.monotonic()
|
|
661
|
+
if pending_dirty and now - last_render >= min_interval:
|
|
662
|
+
self._render_state.msgs_batched = msgs_since_render
|
|
663
|
+
self._render_state.msg_types_batched = dict(msg_types_since_render)
|
|
664
|
+
self._render()
|
|
665
|
+
last_render = now
|
|
666
|
+
pending_dirty = False
|
|
667
|
+
msgs_since_render = 0
|
|
668
|
+
msg_types_since_render.clear()
|
|
669
|
+
|
|
670
|
+
finally:
|
|
671
|
+
# Stop all subscriptions
|
|
672
|
+
self._stop_all_subs()
|
|
673
|
+
terminal.show_cursor()
|
|
674
|
+
terminal.stop()
|
|
675
|
+
self._terminal = None
|
|
676
|
+
# Close wakeup pipe
|
|
677
|
+
try:
|
|
678
|
+
os.close(self._wakeup_r)
|
|
679
|
+
os.close(self._wakeup_w)
|
|
680
|
+
except OSError:
|
|
681
|
+
pass
|
|
682
|
+
|
|
683
|
+
def _parse_input(self, key: str) -> object | None:
|
|
684
|
+
"""Parse raw input into a message type.
|
|
685
|
+
|
|
686
|
+
Handles:
|
|
687
|
+
- Bracketed paste: collects text between ESC[200~ and ESC[201~
|
|
688
|
+
- Mouse events: SGR format ESC[<...M/m
|
|
689
|
+
- Focus events: ESC[I (focus) and ESC[O (blur)
|
|
690
|
+
- Regular keys: everything else
|
|
691
|
+
"""
|
|
692
|
+
# Check for bracketed paste
|
|
693
|
+
if key == _PASTE_START:
|
|
694
|
+
self._paste_buffer = ""
|
|
695
|
+
return None # Don't dispatch yet
|
|
696
|
+
|
|
697
|
+
if self._paste_buffer is not None:
|
|
698
|
+
if key == _PASTE_END:
|
|
699
|
+
# Paste complete
|
|
700
|
+
text = self._paste_buffer
|
|
701
|
+
self._paste_buffer = None
|
|
702
|
+
return PasteEvent(text=text)
|
|
703
|
+
else:
|
|
704
|
+
# Accumulate paste content
|
|
705
|
+
self._paste_buffer += key
|
|
706
|
+
return None # Don't dispatch yet
|
|
707
|
+
|
|
708
|
+
# Check for mouse event
|
|
709
|
+
mouse = _parse_mouse_sgr(key)
|
|
710
|
+
if mouse is not None:
|
|
711
|
+
return mouse
|
|
712
|
+
|
|
713
|
+
# Check for focus event
|
|
714
|
+
focus = _parse_focus(key)
|
|
715
|
+
if focus is not None:
|
|
716
|
+
return focus
|
|
717
|
+
|
|
718
|
+
# Regular key
|
|
719
|
+
return KeyPress(key=key)
|
|
720
|
+
|
|
721
|
+
def _dispatch(self, msg: object) -> None:
|
|
722
|
+
"""Send message through update, execute resulting command."""
|
|
723
|
+
if not self._running:
|
|
724
|
+
return
|
|
725
|
+
self._model, cmd = self._update_fn(self._model, msg)
|
|
726
|
+
self._execute_cmd(cmd)
|
|
727
|
+
|
|
728
|
+
def _execute_cmd(self, cmd: Cmd) -> None:
|
|
729
|
+
"""Execute a command."""
|
|
730
|
+
if cmd._kind == "none":
|
|
731
|
+
return
|
|
732
|
+
elif cmd._kind == "quit":
|
|
733
|
+
self._running = False
|
|
734
|
+
elif cmd._kind == "batch":
|
|
735
|
+
for c in cmd._data:
|
|
736
|
+
self._execute_cmd(c)
|
|
737
|
+
elif cmd._kind == "task":
|
|
738
|
+
fn = cmd._data
|
|
739
|
+
q = self._msg_queue
|
|
740
|
+
|
|
741
|
+
def run() -> None:
|
|
742
|
+
result = fn()
|
|
743
|
+
q.put(result)
|
|
744
|
+
|
|
745
|
+
t = threading.Thread(target=run, daemon=True)
|
|
746
|
+
t.start()
|
|
747
|
+
|
|
748
|
+
def _render(self) -> None:
|
|
749
|
+
"""Render current model to terminal."""
|
|
750
|
+
if self._terminal is None:
|
|
751
|
+
return
|
|
752
|
+
width = self._terminal.columns
|
|
753
|
+
height = self._terminal.rows
|
|
754
|
+
lines = self._view_fn(self._model, width, height)
|
|
755
|
+
diff_render(self._terminal, lines, self._render_state)
|
|
756
|
+
|
|
757
|
+
self._frame_count += 1
|
|
758
|
+
if self._debug_fn is not None and self._frame_count % self._debug_frame_interval == 0:
|
|
759
|
+
self._debug_fn(self._model, width, height, self._frame_count)
|
|
760
|
+
|
|
761
|
+
# ---------------------------------------------------------------------------
|
|
762
|
+
# Subscription lifecycle
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
def _sync_subs(self, wanted: Sub) -> None:
|
|
766
|
+
"""Start/stop subscriptions to match what's wanted."""
|
|
767
|
+
wanted_flat = _flatten_subs(wanted)
|
|
768
|
+
wanted_keys = {_sub_key(s): s for s in wanted_flat}
|
|
769
|
+
|
|
770
|
+
# Stop subs that are no longer wanted
|
|
771
|
+
to_stop = [k for k in self._active_subs if k not in wanted_keys]
|
|
772
|
+
for k in to_stop:
|
|
773
|
+
runner = self._active_subs.pop(k)
|
|
774
|
+
runner.stop.set()
|
|
775
|
+
|
|
776
|
+
# Start subs that are new
|
|
777
|
+
for k, sub in wanted_keys.items():
|
|
778
|
+
if k not in self._active_subs:
|
|
779
|
+
self._start_sub(k, sub)
|
|
780
|
+
|
|
781
|
+
def _start_sub(self, key: tuple, sub: Sub) -> None:
|
|
782
|
+
"""Start a subscription thread."""
|
|
783
|
+
stop = threading.Event()
|
|
784
|
+
|
|
785
|
+
if sub._kind == "every":
|
|
786
|
+
interval, msg_fn = sub._data
|
|
787
|
+
t = threading.Thread(
|
|
788
|
+
target=_run_every,
|
|
789
|
+
args=(interval, msg_fn, self._msg_queue, stop),
|
|
790
|
+
daemon=True,
|
|
791
|
+
)
|
|
792
|
+
elif sub._kind == "file_tail":
|
|
793
|
+
path, msg_fn = sub._data
|
|
794
|
+
t = threading.Thread(
|
|
795
|
+
target=_run_file_tail,
|
|
796
|
+
args=(path, msg_fn, self._msg_queue, stop),
|
|
797
|
+
daemon=True,
|
|
798
|
+
)
|
|
799
|
+
else:
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
t.start()
|
|
803
|
+
self._active_subs[key] = _SubRunner(key=key, stop=stop, thread=t)
|
|
804
|
+
|
|
805
|
+
def _stop_all_subs(self) -> None:
|
|
806
|
+
"""Stop all running subscriptions."""
|
|
807
|
+
for runner in self._active_subs.values():
|
|
808
|
+
runner.stop.set()
|
|
809
|
+
self._active_subs.clear()
|