tappty 0.1.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.
- tappty/__init__.py +88 -0
- tappty/arcade_ui.py +339 -0
- tappty/bus.py +520 -0
- tappty/cli.py +534 -0
- tappty/compositor.py +425 -0
- tappty/curses_ui.py +289 -0
- tappty/keys.py +56 -0
- tappty/pygame_ui.py +180 -0
- tappty/pyte_terminal.py +156 -0
- tappty/recorder.py +203 -0
- tappty/session.py +334 -0
- tappty/source.py +717 -0
- tappty/style.py +141 -0
- tappty/terminal.py +144 -0
- tappty/video.py +221 -0
- tappty/web_ui.py +299 -0
- tappty-0.1.0.dist-info/METADATA +224 -0
- tappty-0.1.0.dist-info/RECORD +22 -0
- tappty-0.1.0.dist-info/WHEEL +5 -0
- tappty-0.1.0.dist-info/entry_points.txt +2 -0
- tappty-0.1.0.dist-info/licenses/LICENSE +21 -0
- tappty-0.1.0.dist-info/top_level.txt +1 -0
tappty/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""tappty -- a small instrumented-terminal toolkit.
|
|
2
|
+
|
|
3
|
+
Host a program -- a subprocess on a pseudo-terminal, or any in-process runner -- in a
|
|
4
|
+
fixed-size character Terminal, observe and control it through a uniform set of taps (in
|
|
5
|
+
process) or a socket bus (out of process), and render it in a curses (CUI) or pygame
|
|
6
|
+
(GUI) window. Several sessions tile into one window via the compositor.
|
|
7
|
+
|
|
8
|
+
The pieces are decoupled: the Source produces bytes, the Terminal models the glass, the
|
|
9
|
+
Session fans output to observers and routes input back, and a renderer is just one more
|
|
10
|
+
observer/controller. That is what lets an AI watch and drive the same session a human
|
|
11
|
+
sees.
|
|
12
|
+
|
|
13
|
+
Quick start::
|
|
14
|
+
|
|
15
|
+
from tappty import Session, Terminal, PtySource, curses_ui
|
|
16
|
+
sess = Session(Terminal())
|
|
17
|
+
sess.source = PtySource(["bash"])
|
|
18
|
+
sess.claim_control("local", "human")
|
|
19
|
+
curses_ui.run(sess, None, title="bash")
|
|
20
|
+
|
|
21
|
+
Or just use the ``tapterm`` command-line program (see tappty.cli).
|
|
22
|
+
|
|
23
|
+
Public API:
|
|
24
|
+
Terminal -- the fixed-size character grid model (VT52 spirit)
|
|
25
|
+
PyteTerminal -- full-ANSI/VT100+ backend (the `ansi` extra; pyte)
|
|
26
|
+
Session -- hosts a Source; exposes observe taps + control
|
|
27
|
+
Source, PtySource, EngineSource -- byte producers (pty subprocess / in-process runner)
|
|
28
|
+
CastSource, TtyrecSource, AnsSource, ThreeASource, replay_source
|
|
29
|
+
-- replay recordings / art (.cast / .ttyrec / .ans / .3a)
|
|
30
|
+
PipeSource, ConPtySource -- non-pty pipes (any OS) / Windows ConPTY (pywinpty)
|
|
31
|
+
Recorder, render_video, export_ansi, export_3a
|
|
32
|
+
-- record a session, render it to video, export the screen
|
|
33
|
+
BusServer, BusClient -- out-of-process observe/control over a unix socket or TCP
|
|
34
|
+
curses_ui, pygame_ui, arcade_ui, web_ui -- renderers; each: run(session, runner, title=...)
|
|
35
|
+
compositor -- multi-panel single-window dashboard
|
|
36
|
+
style -- Cell + color helpers behind the backends' cells() / the GUIs
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from tappty import arcade_ui, compositor, curses_ui, pygame_ui, style, web_ui
|
|
40
|
+
from tappty.bus import BusClient, BusServer
|
|
41
|
+
from tappty.pyte_terminal import PyteTerminal
|
|
42
|
+
from tappty.recorder import Recorder, export_3a, export_ansi
|
|
43
|
+
from tappty.session import Session
|
|
44
|
+
from tappty.source import (
|
|
45
|
+
AnsSource,
|
|
46
|
+
CastSource,
|
|
47
|
+
ConPtySource,
|
|
48
|
+
EngineSource,
|
|
49
|
+
PipeSource,
|
|
50
|
+
PtySource,
|
|
51
|
+
Source,
|
|
52
|
+
ThreeASource,
|
|
53
|
+
TtyrecSource,
|
|
54
|
+
replay_source,
|
|
55
|
+
)
|
|
56
|
+
from tappty.terminal import Terminal
|
|
57
|
+
from tappty.video import render_video
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"Terminal",
|
|
63
|
+
"PyteTerminal",
|
|
64
|
+
"Session",
|
|
65
|
+
"Source",
|
|
66
|
+
"PtySource",
|
|
67
|
+
"EngineSource",
|
|
68
|
+
"CastSource",
|
|
69
|
+
"TtyrecSource",
|
|
70
|
+
"AnsSource",
|
|
71
|
+
"ThreeASource",
|
|
72
|
+
"replay_source",
|
|
73
|
+
"PipeSource",
|
|
74
|
+
"ConPtySource",
|
|
75
|
+
"Recorder",
|
|
76
|
+
"export_ansi",
|
|
77
|
+
"export_3a",
|
|
78
|
+
"render_video",
|
|
79
|
+
"BusServer",
|
|
80
|
+
"BusClient",
|
|
81
|
+
"curses_ui",
|
|
82
|
+
"pygame_ui",
|
|
83
|
+
"arcade_ui",
|
|
84
|
+
"web_ui",
|
|
85
|
+
"compositor",
|
|
86
|
+
"style",
|
|
87
|
+
"__version__",
|
|
88
|
+
]
|
tappty/arcade_ui.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""A live arcade renderer for a Session -- a green-phosphor VT52-style window.
|
|
2
|
+
|
|
3
|
+
The arcade (pyglet/OpenGL) twin of `pygame_ui`: the same `run(session, runner, ...)`
|
|
4
|
+
shape, the same green-on-near-black character grid, scrollback, snapshots, and owning
|
|
5
|
+
teardown. Having two graphical renderers that share nothing but the Session contract is
|
|
6
|
+
the point -- a renderer is just an adapter over the UI-agnostic core.
|
|
7
|
+
|
|
8
|
+
`arcade` is the `gl` extra and is imported lazily (the window class is built on first
|
|
9
|
+
`run()`), so `import tappty` and `import tappty.arcade_ui` work with arcade absent. Each
|
|
10
|
+
row is drawn as a single pooled `arcade.Text` (cheap, and a monospace font keeps the
|
|
11
|
+
columns aligned), with the cursor and scrollback bar drawn as primitives.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from tappty import keys, style
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
|
22
|
+
FG = style.FG # phosphor green -- the "default" SGR color resolves to this
|
|
23
|
+
BG = style.BG
|
|
24
|
+
|
|
25
|
+
# Monospace families to try by name when the bundled DejaVu path is absent (a
|
|
26
|
+
# non-monospace fallback would misalign the grid, so we look hard before giving up).
|
|
27
|
+
_MONO_FALLBACKS = ("DejaVu Sans Mono", "Liberation Mono", "Consolas", "Courier New", "monospace")
|
|
28
|
+
|
|
29
|
+
_WINDOW_CLASS = None # the arcade.Window subclass, built once arcade is importable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_mono_font():
|
|
33
|
+
"""Register the bundled DejaVu Sans Mono if present, else find a system monospace
|
|
34
|
+
family. Returns the family name to pass to `arcade.Text`, or None for arcade's default
|
|
35
|
+
(which is not monospace -- a last resort)."""
|
|
36
|
+
import arcade
|
|
37
|
+
import pyglet
|
|
38
|
+
|
|
39
|
+
if os.path.exists(FONT_PATH):
|
|
40
|
+
try:
|
|
41
|
+
arcade.load_font(FONT_PATH)
|
|
42
|
+
return "DejaVu Sans Mono"
|
|
43
|
+
except Exception as e: # unreadable/odd font file -- fall through to system fonts
|
|
44
|
+
log.debug("load_font(%s) failed: %s", FONT_PATH, e)
|
|
45
|
+
for name in _MONO_FALLBACKS:
|
|
46
|
+
try:
|
|
47
|
+
if pyglet.font.have_font(name):
|
|
48
|
+
return name
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cell_metrics(font_name, font_size):
|
|
55
|
+
"""The monospace cell size (advance width, line height) in pixels. Needs a live GL
|
|
56
|
+
context (glyph layout), so call this only after the window exists."""
|
|
57
|
+
import arcade
|
|
58
|
+
import pyglet
|
|
59
|
+
|
|
60
|
+
probe = arcade.Text("M" * 10, 0, 0, FG, font_size, font_name=font_name)
|
|
61
|
+
cw = probe.content_width / 10.0
|
|
62
|
+
try:
|
|
63
|
+
pf = pyglet.font.load(font_name, font_size)
|
|
64
|
+
chh = pf.ascent - pf.descent # descent is negative, so this is the full line height
|
|
65
|
+
except Exception:
|
|
66
|
+
chh = probe.content_height
|
|
67
|
+
return max(1.0, cw), max(1.0, chh)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_text_snapshot(path, session):
|
|
71
|
+
try:
|
|
72
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
73
|
+
f.write(session.term.snapshot())
|
|
74
|
+
except OSError as e: # snapshots are best-effort -- never kill the render loop
|
|
75
|
+
log.debug("snapshot write failed: %s", e)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_png(w, h, path):
|
|
79
|
+
import arcade
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
arcade.get_image(0, 0, w, h).save(path)
|
|
83
|
+
except Exception as e: # best-effort, like the text snapshot
|
|
84
|
+
log.debug("PNG snapshot failed: %s", e)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _window_class():
|
|
88
|
+
"""Build (once) and return the arcade.Window subclass. Deferred so the arcade import
|
|
89
|
+
stays lazy -- a class can't subclass arcade.Window until arcade is imported."""
|
|
90
|
+
global _WINDOW_CLASS
|
|
91
|
+
if _WINDOW_CLASS is not None:
|
|
92
|
+
return _WINDOW_CLASS
|
|
93
|
+
|
|
94
|
+
import arcade
|
|
95
|
+
|
|
96
|
+
class _TerminalWindow(arcade.Window):
|
|
97
|
+
def __init__(
|
|
98
|
+
self, session, title, snapshot_path, font_size, exit_when_done, fps, max_seconds
|
|
99
|
+
):
|
|
100
|
+
self.session = session
|
|
101
|
+
cols, rows = session.term.cols, session.term.rows
|
|
102
|
+
# Provisional size to get a GL context; the exact grid size needs glyph metrics,
|
|
103
|
+
# which need that context -- so measure, then resize.
|
|
104
|
+
est_cw, est_chh = font_size * 0.62, font_size * 1.3
|
|
105
|
+
super().__init__(
|
|
106
|
+
max(1, int(cols * est_cw)),
|
|
107
|
+
max(1, int(rows * est_chh)),
|
|
108
|
+
title,
|
|
109
|
+
update_rate=1.0 / fps,
|
|
110
|
+
draw_rate=1.0 / fps, # arcade asserts draw_rate >= update_rate
|
|
111
|
+
)
|
|
112
|
+
self.background_color = BG
|
|
113
|
+
self._cols, self._rows = cols, rows
|
|
114
|
+
self._snapshot_path = snapshot_path
|
|
115
|
+
self._exit_when_done = exit_when_done
|
|
116
|
+
self._max_seconds = max_seconds
|
|
117
|
+
self._scroll = 0
|
|
118
|
+
self._page = max(1, rows - 1)
|
|
119
|
+
self._t = 0.0 # seconds since start (drives blink, snapshots, the cap)
|
|
120
|
+
self._done_t = 0.0
|
|
121
|
+
self._last_snap = -1.0
|
|
122
|
+
self._want_png = False
|
|
123
|
+
self._font = _load_mono_font()
|
|
124
|
+
self._cw, self._chh = _cell_metrics(self._font, font_size)
|
|
125
|
+
self.set_size(int(cols * self._cw), int(rows * self._chh))
|
|
126
|
+
self._font_size = font_size
|
|
127
|
+
# A pool of reused arcade.Text objects -- one per style-run drawn this frame
|
|
128
|
+
# (a run = consecutive same-colored cells; a monospace font keeps them aligned).
|
|
129
|
+
# The pool grows to the frame's peak run count, then stops allocating. The
|
|
130
|
+
# inverted scrollback tag is its own reused Text.
|
|
131
|
+
self._text_pool = []
|
|
132
|
+
self._text_used = 0
|
|
133
|
+
self._tag_text = arcade.Text(
|
|
134
|
+
"", 0, 0, BG, font_size, font_name=self._font, anchor_y="top"
|
|
135
|
+
)
|
|
136
|
+
# raw-mode key translation (full-TUI input): an arcade keycode -> VT key name
|
|
137
|
+
k = arcade.key
|
|
138
|
+
self._raw_special = {
|
|
139
|
+
k.UP: "up",
|
|
140
|
+
k.DOWN: "down",
|
|
141
|
+
k.LEFT: "left",
|
|
142
|
+
k.RIGHT: "right",
|
|
143
|
+
k.HOME: "home",
|
|
144
|
+
k.END: "end",
|
|
145
|
+
k.PAGEUP: "pageup",
|
|
146
|
+
k.PAGEDOWN: "pagedown",
|
|
147
|
+
k.INSERT: "insert",
|
|
148
|
+
k.DELETE: "delete",
|
|
149
|
+
k.RETURN: "enter",
|
|
150
|
+
k.NUM_ENTER: "enter",
|
|
151
|
+
k.BACKSPACE: "backspace",
|
|
152
|
+
k.TAB: "tab",
|
|
153
|
+
k.ESCAPE: "escape",
|
|
154
|
+
}
|
|
155
|
+
for _i in range(1, 13):
|
|
156
|
+
self._raw_special[getattr(k, f"F{_i}")] = f"f{_i}"
|
|
157
|
+
|
|
158
|
+
def _raw_key(self, symbol, modifiers):
|
|
159
|
+
"""Bytes to send the program for a non-text key in raw mode, or None (printable
|
|
160
|
+
text arrives via on_text)."""
|
|
161
|
+
name = self._raw_special.get(symbol)
|
|
162
|
+
if name is not None:
|
|
163
|
+
return keys.KEYS[name]
|
|
164
|
+
if modifiers & arcade.key.MOD_CTRL and 97 <= symbol <= 122: # Ctrl-letter
|
|
165
|
+
return keys.ctrl(chr(symbol))
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def _draw_text(
|
|
169
|
+
self, text, x, y, color, bold=False, italic=False, underline=False, strike=False
|
|
170
|
+
):
|
|
171
|
+
if self._text_used < len(self._text_pool):
|
|
172
|
+
t = self._text_pool[self._text_used]
|
|
173
|
+
t.text, t.x, t.y, t.color = text, x, y, color
|
|
174
|
+
t.bold, t.italic = bold, italic
|
|
175
|
+
else:
|
|
176
|
+
t = arcade.Text(
|
|
177
|
+
text,
|
|
178
|
+
x,
|
|
179
|
+
y,
|
|
180
|
+
color,
|
|
181
|
+
self._font_size,
|
|
182
|
+
font_name=self._font,
|
|
183
|
+
anchor_y="top",
|
|
184
|
+
bold=bold,
|
|
185
|
+
italic=italic,
|
|
186
|
+
)
|
|
187
|
+
self._text_pool.append(t)
|
|
188
|
+
self._text_used += 1
|
|
189
|
+
t.draw()
|
|
190
|
+
# arcade Text has no underline/strikethrough -> draw rules ourselves
|
|
191
|
+
x1 = x + len(text) * self._cw
|
|
192
|
+
if underline:
|
|
193
|
+
arcade.draw_line(x, y - self._chh + 2, x1, y - self._chh + 2, color, 1)
|
|
194
|
+
if strike:
|
|
195
|
+
arcade.draw_line(x, y - self._chh / 2, x1, y - self._chh / 2, color, 1)
|
|
196
|
+
|
|
197
|
+
def on_update(self, dt):
|
|
198
|
+
self._t += dt
|
|
199
|
+
if self._snapshot_path and self._t - self._last_snap >= 1.0:
|
|
200
|
+
self._last_snap = self._t
|
|
201
|
+
_write_text_snapshot(self._snapshot_path, self.session)
|
|
202
|
+
self._want_png = True # captured next on_draw (needs the GL framebuffer)
|
|
203
|
+
if self.session.done:
|
|
204
|
+
self._done_t += dt
|
|
205
|
+
if self._exit_when_done and self._done_t > 1.0:
|
|
206
|
+
self.close()
|
|
207
|
+
if self._max_seconds is not None and self._t >= self._max_seconds:
|
|
208
|
+
self.close() # hard cap (scripting/tests)
|
|
209
|
+
|
|
210
|
+
def on_draw(self):
|
|
211
|
+
self.clear()
|
|
212
|
+
term = self.session.term
|
|
213
|
+
self._text_used = 0 # rewind the Text pool for this frame
|
|
214
|
+
blink_on = int(self._t * 1.5) % 2 == 0
|
|
215
|
+
for r, row in enumerate(term.cells(self._scroll)):
|
|
216
|
+
top = self.height - r * self._chh
|
|
217
|
+
for run in style.runs(row, FG, BG):
|
|
218
|
+
x, text, fg, bg, bold, italic, underline, strike, blink = run
|
|
219
|
+
left = x * self._cw
|
|
220
|
+
if bg != BG: # fill only non-default backgrounds
|
|
221
|
+
arcade.draw_lrbt_rectangle_filled(
|
|
222
|
+
left, left + len(text) * self._cw, top - self._chh, top, bg
|
|
223
|
+
)
|
|
224
|
+
if blink and not blink_on: # blinking run on its hidden phase
|
|
225
|
+
continue
|
|
226
|
+
self._draw_text(text, left, top, fg, bold, italic, underline, strike)
|
|
227
|
+
if self._scroll == 0:
|
|
228
|
+
if int(self._t * 2) % 2 == 0: # blink ~1 Hz (live only)
|
|
229
|
+
left = term.cx * self._cw
|
|
230
|
+
top = self.height - term.cy * self._chh
|
|
231
|
+
arcade.draw_lrbt_rectangle_outline(
|
|
232
|
+
left, left + self._cw, top - self._chh, top, FG, 1
|
|
233
|
+
)
|
|
234
|
+
else: # scrollback indicator on the last row (inverted: BG on FG)
|
|
235
|
+
y = self.height - (self._rows - 1) * self._chh
|
|
236
|
+
arcade.draw_lrbt_rectangle_filled(0, self.width, y - self._chh, y, FG)
|
|
237
|
+
self._tag_text.text = (
|
|
238
|
+
f" -- SCROLLBACK {self._scroll}/{term.max_scroll()} (PgDn / type to resume) "
|
|
239
|
+
)
|
|
240
|
+
self._tag_text.x, self._tag_text.y, self._tag_text.color = 0, y, BG
|
|
241
|
+
self._tag_text.draw()
|
|
242
|
+
if self._want_png:
|
|
243
|
+
self._want_png = False
|
|
244
|
+
_save_png(
|
|
245
|
+
self.width,
|
|
246
|
+
self.height,
|
|
247
|
+
(self._snapshot_path + ".png") if self._snapshot_path else "/tmp/tapterm.png",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def on_text(self, text): # printable input; specials/Enter/etc. via on_key_press
|
|
251
|
+
if not (text and text.isprintable()):
|
|
252
|
+
return
|
|
253
|
+
self._scroll = 0 # any typing snaps back to live
|
|
254
|
+
send = self.session.send_key if self.session.raw_keys else self.session.feed_key
|
|
255
|
+
for ch in text:
|
|
256
|
+
send(ch)
|
|
257
|
+
|
|
258
|
+
def on_key_press(self, symbol, modifiers):
|
|
259
|
+
k = arcade.key
|
|
260
|
+
if symbol == k.F12:
|
|
261
|
+
self._want_png = True # save a screenshot (always local)
|
|
262
|
+
return
|
|
263
|
+
if symbol == k.BRACKETRIGHT and (modifiers & k.MOD_CTRL):
|
|
264
|
+
self.close() # Ctrl-] : force quit (always local, parity with the curses UI)
|
|
265
|
+
return
|
|
266
|
+
if self.session.raw_keys: # full-TUI mode: forward keystrokes raw
|
|
267
|
+
data = self._raw_key(symbol, modifiers)
|
|
268
|
+
if data is not None:
|
|
269
|
+
self._scroll = 0
|
|
270
|
+
self.session.send_key(data)
|
|
271
|
+
return
|
|
272
|
+
if symbol in (k.RETURN, k.NUM_ENTER):
|
|
273
|
+
self._scroll = 0
|
|
274
|
+
self.session.feed_key("\r")
|
|
275
|
+
elif symbol == k.BACKSPACE:
|
|
276
|
+
self._scroll = 0
|
|
277
|
+
self.session.feed_key("\b")
|
|
278
|
+
elif symbol == k.PAGEUP:
|
|
279
|
+
self._scroll = min(self.session.term.max_scroll(), self._scroll + self._page)
|
|
280
|
+
elif symbol == k.PAGEDOWN:
|
|
281
|
+
self._scroll = max(0, self._scroll - self._page)
|
|
282
|
+
|
|
283
|
+
def on_mouse_scroll(self, x, y, sx, sy): # wheel up = back into the paper roll
|
|
284
|
+
self._scroll = max(0, min(self.session.term.max_scroll(), self._scroll + sy))
|
|
285
|
+
|
|
286
|
+
_WINDOW_CLASS = _TerminalWindow
|
|
287
|
+
return _WINDOW_CLASS
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_window(
|
|
291
|
+
session,
|
|
292
|
+
title="tapterm",
|
|
293
|
+
snapshot_path=None,
|
|
294
|
+
font_size=18,
|
|
295
|
+
exit_when_done=False,
|
|
296
|
+
fps=30,
|
|
297
|
+
max_seconds=None,
|
|
298
|
+
):
|
|
299
|
+
"""Construct the terminal window (arcade must be importable). Factored out of `run` so
|
|
300
|
+
a test can pump `on_update`/`on_draw` by hand instead of entering arcade's event loop."""
|
|
301
|
+
import pyglet
|
|
302
|
+
|
|
303
|
+
# pyglet (arcade's backend) probes an audio driver at import; on a host with no sound
|
|
304
|
+
# server (e.g. WSLg after its audio service drops out) that probe blocks ~55s before the
|
|
305
|
+
# window can open. We never play audio, so force the silent driver before importing arcade.
|
|
306
|
+
pyglet.options["audio"] = ("silent",)
|
|
307
|
+
return _window_class()(
|
|
308
|
+
session, title, snapshot_path, font_size, exit_when_done, fps, max_seconds
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def run(
|
|
313
|
+
session,
|
|
314
|
+
runner,
|
|
315
|
+
title="tapterm",
|
|
316
|
+
snapshot_path=None,
|
|
317
|
+
font_size=18,
|
|
318
|
+
exit_when_done=False,
|
|
319
|
+
fps=30,
|
|
320
|
+
max_seconds=None,
|
|
321
|
+
):
|
|
322
|
+
if fps < 1:
|
|
323
|
+
raise ValueError("fps must be >= 1")
|
|
324
|
+
import arcade
|
|
325
|
+
|
|
326
|
+
window = _build_window(
|
|
327
|
+
session, title, snapshot_path, font_size, exit_when_done, fps, max_seconds
|
|
328
|
+
)
|
|
329
|
+
session.run_in_thread(runner) # start the hosted program
|
|
330
|
+
try:
|
|
331
|
+
arcade.run() # blocks until the window closes (done/cap/Ctrl-]/the close button)
|
|
332
|
+
finally:
|
|
333
|
+
if snapshot_path: # final snapshot
|
|
334
|
+
_write_text_snapshot(snapshot_path, session)
|
|
335
|
+
session.stop() # owning renderer: stop the hosted source when the window closes
|
|
336
|
+
try:
|
|
337
|
+
window.close()
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|