1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
|
#!/usr/bin/env python3
"""Record a scripted slosh session into a sloshcast.
A recording here is a *program*: a demo script drives `slosh --script`
(through tests/harness.py, the same Session the test suite uses), advances a
virtual clock with authored pauses and a typing cadence, and captures the
compositor's own snapshot after every action. Frames arrive with colours
already resolved to hex by the theme, so the browser player needs no VT, no
palette, no wasm -- it replays styled rows into a DOM grid.
The format ("sloshcast", one JSON file):
{
"v": 1,
"cols": 100, "rows": 28, # the grid at t=0
"fg": "#ffffff", "bg": "#000000", # the theme's defaults
"title": "...", "dur": 14.2, # seconds, including the tail
"events": [
{"t": 0.0,
"rows": {"0": [text, [[x, w, fg, bg, attrbits], ...]], ...},
"cur": [x, y]}, # only what changed; event 0 is full
{"t": 3.1, "size": [80, 20], "rows": {...}}, # a resize: full frame
{"t": 5.0, "ptr": [40, 12]}, # the pointer track: cell, [x,y,1]
... # while the button is down, null gone
]
}
attrbits: 1 bold, 2 italic, 4 underline, 8 dim, 16 reverse, 32 strike.
Timing is entirely virtual -- the same script yields the same file, byte for
byte, no matter how loaded the machine that rendered it was.
"""
import json
import os
import re
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, os.path.join(ROOT, "tests"))
from harness import Session # noqa: E402
THEME = os.path.join(ROOT, "contrib", "themes", "sl0p.kdl")
ATTR_BITS = {
"bold": 1,
"italic": 2,
"underline": 4,
"dim": 8,
"faint": 8,
"reverse": 16,
"inverse": 16,
"strike": 32,
"strikethrough": 32,
}
def _theme_defaults(path):
"""default_fg / default_bg out of a theme file, for the player's canvas."""
fg, bg = "#ffffff", "#000000"
try:
text = open(path).read()
m = re.search(r'default_fg\s+"(#[0-9a-fA-F]{6})"', text)
if m:
fg = m.group(1)
m = re.search(r'default_bg\s+"(#[0-9a-fA-F]{6})"', text)
if m:
bg = m.group(1)
except OSError:
pass
return fg, bg
class Recorder:
def __init__(
self,
argv=None,
cols=100,
rows=28,
title="",
config=THEME,
env=None,
layout=None,
):
self.s = Session(
list(argv or ["/bin/sh"]),
cols=cols,
rows=rows,
config=config,
env=env,
layout=layout,
)
self.api = self.s.api
self.title = title
self.fg, self.bg = _theme_defaults(config) if config else ("#ffffff", "#000000")
self.t = 0.0
self.events = []
self._size = (cols, rows)
self._size0 = (cols, rows)
self._rows = None # previous frame, list of [text, runs]
self._cur = None
self._seed = 0x5105 # deterministic typing jitter
self._ptr = None # pointer as last emitted: [x, y] / [x, y, 1] / None
self._at = None # pointer cell right now, (x, y)
self._down = False
# -- the clock and the capture ---------------------------------------
def _jitter(self):
self._seed = (self._seed * 1103515245 + 12345) & 0x7FFFFFFF
return self._seed / 0x7FFFFFFF
def _frame(self, snap):
runs_by_y = {}
for r in snap.styles:
bits = 0
for a in r.get("attrs", ()):
bits |= ATTR_BITS.get(a, 0)
runs_by_y.setdefault(r["y"], []).append(
[r["x"], r["w"], r["fg"], r["bg"], bits]
)
return [
[text, sorted(runs_by_y.get(y, []))] for y, text in enumerate(snap.text)
]
def _record(self, frame, cur, size, dt=0.0, force=False):
"""Advance the clock by dt, then record whatever changed."""
self.t += dt
ev = {"t": round(self.t, 3)}
if size != self._size:
ev["size"] = list(size)
self._rows = None # a resize event carries the full frame
changed = {
str(y): row
for y, row in enumerate(frame)
if self._rows is None or y >= len(self._rows) or self._rows[y] != row
}
if changed:
ev["rows"] = changed
if cur != self._cur:
ev["cur"] = cur
ptr = None if self._at is None else list(self._at) + ([1] if self._down else [])
if ptr != self._ptr:
ev["ptr"] = ptr
self._ptr = ptr
if len(ev) > 1 or force:
self.events.append(ev)
self._rows, self._cur, self._size = frame, cur, size
def capture(self, dt=0.0, settle_ms=20, force=False):
"""Advance the clock by dt, then record whatever changed on screen."""
self.s.settle(settle_ms)
snap = self.s.snapshot()
cur = [snap.cursor["x"], snap.cursor["y"]] if snap.cursor["visible"] else None
self._record(self._frame(snap), cur, (snap.cols, snap.rows), dt=dt, force=force)
# -- more than one session in a recording -------------------------------
def switch(self, session, dt=0.0, settle_ms=100):
"""Record from another live Session from here on. The next frame is
full, which is what a real attach paints anyway. The session keeps
living (and can be poked over its api) while it is off camera --
that is the whole point of the detach demo."""
self.s = session
self.api = session.api
self._rows = None
self.capture(dt=dt, settle_ms=settle_ms, force=True)
def shell(self, lines, dt=0.12):
"""A synthetic bare-terminal frame: the interstitial shell between
detaching from one session and attaching the next. The writer owns
the byte stream, so the frames between real segments can simply be
authored -- plain rows, no styling, a cursor at the end."""
cols, rows = self._size
frame = [[l[:cols], []] for l in lines[:rows]]
frame += [["", []] for _ in range(rows - len(frame))]
cur = None
if lines:
y = min(len(lines), rows) - 1
cur = [min(len(lines[-1]), cols - 1), y]
self._record(frame, cur, self._size, dt=dt)
def shell_type(self, lines, text, cps=0.07):
"""Type onto the last line of a synthetic shell, like a person."""
base = lines[-1]
for i in range(1, len(text) + 1):
lines[-1] = base + text[:i]
self.shell(lines, dt=cps * (0.6 + 0.8 * self._jitter()))
# -- what a demo script says -----------------------------------------
def pause(self, sec):
"""Authored silence. Also lands any output that arrived meanwhile.
Time inside slosh is real: a toast expires and a shader animates on
the monotonic clock, not ours. So when the session says it wants a
next frame (deadline >= 0) within this pause, actually wait that long
and sample the frame -- toasts fade mid-pause exactly as they would
live, and an animation is captured at the cadence it asked for.
Otherwise the pause costs nothing but the authored seconds."""
left = sec
while left > 0:
self.capture() # compose, so deadline() is about a real frame
d = self.s.deadline()
if d < 0 or d / 1000.0 >= left:
break
wait = max(d / 1000.0, 0.03)
time.sleep(wait) # let slosh's own clock reach the deadline
left -= wait
self.capture(dt=wait, settle_ms=5)
self.capture(dt=left)
def hold(self, sec):
"""Advance the clock without touching the session: authored silence
over synthetic frames (the shell between two sessions), where pause()
would ask a live session for its deadline."""
self.t += sec
def wall(self, sec, step=0.1):
"""Spend real seconds, sampling as they pass -- for a program in a
pane that needs actual time (a background job about to ring the
bell), where pause()'s deadline shortcut would skip past it."""
n = max(1, int(sec / step))
for _ in range(n):
time.sleep(step)
self.capture(dt=step, settle_ms=5)
def type(self, text, cps=0.06, dt=0.0):
"""Type like a person: per-character frames, deterministic jitter."""
self.capture(dt=dt)
for ch in text:
self.api("send", data=ch)
self.capture(dt=cps * (0.6 + 0.8 * self._jitter()), settle_ms=8)
def enter(self, dt=0.15):
self.api("send", data="\r")
self.capture(dt=dt)
def run(self, text, wait=None, dt=0.35, **kw):
"""Type a command, press enter, optionally wait for its output."""
self.type(text)
self.enter()
if wait is not None:
self.s.until_text(wait, **kw)
self.capture(dt=dt)
def key(self, chord, dt=0.45):
"""A leader chord: key('\\\\') is C-a \\, key('c') is C-a c.
The JSON `send` takes bytes verbatim (JSON strings carry control
characters natively); the \\x01 escape language belongs to the
bare-verb form only.
"""
self.api("send", data="\x01" + chord)
self.capture(dt=dt)
def send(self, data, dt=0.3):
"""Bytes as if typed, verbatim -- pass real control characters."""
self.api("send", data=data)
self.capture(dt=dt)
# -- the mouse ---------------------------------------------------------
# The recording carries a pointer track ("ptr" events): the driver knows
# the pointer's cell at every moment, so the player can draw the pointer
# a screen capture never shows. Real SGR mouse bytes go to slosh at the
# same time, so hover hints, drags and drops are the program's own.
def _mouse(self, code, final="M"):
x, y = self._at
self.api("send", data="\x1b[<%d;%d;%d%s" % (code, x + 1, y + 1, final))
def move_to(self, x, y, dur=0.5, hover=True):
"""Glide the pointer there in small steps; hover events go to slosh."""
sx, sy = self._at if self._at else (x, y)
steps = max(2, int(dur / 0.05))
for i in range(1, steps + 1):
self._at = (
round(sx + (x - sx) * i / steps),
round(sy + (y - sy) * i / steps),
)
if hover:
self._mouse(35)
self.capture(dt=dur / steps, settle_ms=5)
def press(self, dt=0.15):
self._down = True
self._mouse(0, "M")
self.capture(dt=dt)
def release(self, dt=0.2):
self._mouse(0, "m")
self._down = False
self.capture(dt=dt)
def click(self, x, y, move=0.5, dt=0.5):
self.move_to(x, y, dur=move)
self.press()
self.release(dt=dt)
def drag_to(self, x, y, dur=0.7, dt=0.3):
"""With the button held: motion events slosh sees as a drag."""
sx, sy = self._at
steps = max(2, int(dur / 0.05))
for i in range(1, steps + 1):
self._at = (
round(sx + (x - sx) * i / steps),
round(sy + (y - sy) * i / steps),
)
self._mouse(32)
self.capture(dt=dur / steps, settle_ms=5)
self.capture(dt=dt)
def resize(self, cols, rows, dt=0.4):
self.s.resize(cols, rows)
self.capture(dt=dt)
def until_text(self, needle, dt=0.3, **kw):
self.s.until_text(needle, **kw)
self.capture(dt=dt)
def snapshot(self):
return self.s.snapshot()
# -- the file ---------------------------------------------------------
def save(self, path, tail=1.2):
"""Write the sloshcast; `tail` is how long the last frame holds."""
if not self.events:
self.capture(force=True)
data = {
"v": 1,
"cols": self._size0[0],
"rows": self._size0[1],
"fg": self.fg,
"bg": self.bg,
"title": self.title,
"dur": round(self.t + tail, 3),
"events": self.events,
}
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, separators=(",", ":"))
size = os.path.getsize(path)
print(
f"{path}: {len(self.events)} events, {data['dur']}s, {size / 1024:.1f} KiB"
)
def out_path(default_name):
"""Where a demo writes: argv[1], or build/<name>.json beside this file."""
if len(sys.argv) > 1:
return sys.argv[1]
return os.path.join(HERE, "build", default_name + ".json")
|