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 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