diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:08:30 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:08:30 +0200 |
| commit | 7521f217a5ed98cef16db5837a72fae32dce9552 (patch) | |
| tree | 396837adb0cd65066d78f32eb3b6a9de7eabf67a /tests | |
| parent | sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer (diff) | |
| download | slosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.gz slosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.xz slosh-7521f217a5ed98cef16db5837a72fae32dce9552.zip | |
sl0ptty: input decoder + per-pane re-encode
The half libghostty-vt does not do: decode what the outer terminal sends us
into semantic events, then re-encode each event for the target pane against
that pane's own negotiated modes.
Decoder (src/input.c): legacy keys and modifiers, SS3, tilde keys, the kitty
keyboard protocol (CSI u, shifted/base layout sub-params, press/repeat/release,
associated text), SGR mouse incl. drag and wheel, bracketed paste, focus
events, alt-prefix, UTF-8, and partial sequences retained across reads.
Re-encoding (pane_send_key/mouse/paste) drives lib-vt's key and mouse encoders
via setopt_from_terminal, so a pane gets exactly what it negotiated. The mouse
encoder wants surface pixels; we declare a 1x1 pixel cell so cell coordinates
are exact rather than approximated.
The prefix key is handled on decoded events, not bytes, so it cannot be
confused by a byte that merely looks like C-a inside a UTF-8 or escape
sequence.
Tests: 40 table-driven decoder cases (tests/input_test.c) + a live check that
drives the same client into a legacy pane and a kitty/mouse pane and asserts
each receives its own encoding (tests/live_input.py). 59 checks, all green.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/input_test.c | 119 | ||||
| -rw-r--r-- | tests/live_input.py | 129 |
2 files changed, 248 insertions, 0 deletions
diff --git a/tests/input_test.c b/tests/input_test.c new file mode 100644 index 0000000..62c8f5c --- /dev/null +++ b/tests/input_test.c @@ -0,0 +1,119 @@ +/* Table-driven tests for the input decoder. + * + * Decoding is pure, so it gets pure tests: bytes in, event descriptions out. + * Everything that needs a terminal is tested live instead (tests/live_m0.py). + */ +#include "input.h" + +#include <stdio.h> +#include <string.h> + +static char g_log[4096]; +static size_t g_log_len; + +static void collect(const input_event_t *ev, void *ud) { + char line[256]; + input_event_describe(ev, line, sizeof line); + g_log_len += (size_t)snprintf(g_log + g_log_len, sizeof g_log - g_log_len, + "%s%s", g_log_len ? " | " : "", line); +} + +static int fails = 0; + +static void expect(const char *name, const char *bytes, size_t len, + const char *want) { + g_log[0] = 0; + g_log_len = 0; + input_parser_t *p = input_new(); + input_feed(p, (const uint8_t *)bytes, len, collect, NULL); + if (input_pending(p)) input_timeout(p, collect, NULL); + input_free(p); + + bool ok = strcmp(g_log, want) == 0; + if (!ok) fails++; + printf("%s %-34s %s\n", ok ? "ok " : "FAIL", name, ok ? "" : g_log); + if (!ok) printf(" wanted: %s\n", want); +} + +#define T(name, lit, want) expect(name, lit, sizeof(lit) - 1, want) + +/* Split a sequence across two feeds: a real terminal will do this to us. */ +static void expect_split(const char *name, const char *bytes, size_t len, + size_t at, const char *want) { + g_log[0] = 0; + g_log_len = 0; + input_parser_t *p = input_new(); + input_feed(p, (const uint8_t *)bytes, at, collect, NULL); + input_feed(p, (const uint8_t *)bytes + at, len - at, collect, NULL); + input_free(p); + bool ok = strcmp(g_log, want) == 0; + if (!ok) fails++; + printf("%s %-34s %s\n", ok ? "ok " : "FAIL", name, ok ? "" : g_log); + if (!ok) printf(" wanted: %s\n", want); +} + +int main(void) { + /* plain text */ + T("ascii", "a", "key a mods=- press text=a"); + T("shifted ascii", "A", "key a mods=S press text=A"); + T("utf8 two byte", "\xc3\xa9", "key UNIDENTIFIED mods=- press text=\xc3\xa9"); + T("utf8 emoji", "\xf0\x9f\x92\x80", + "key UNIDENTIFIED mods=- press text=\xf0\x9f\x92\x80"); + T("two keys", "hi", "key h mods=- press text=h | key i mods=- press text=i"); + + /* C0 */ + T("ctrl-a", "\x01", "key a mods=C press"); + T("ctrl-c", "\x03", "key c mods=C press"); + T("enter", "\r", "key ENTER mods=- press text=\r"); + T("tab", "\t", "key TAB mods=- press text=\t"); + T("backspace", "\x7f", "key BACKSPACE mods=- press"); + T("ctrl-space", "\x00", "key SPACE mods=C press"); + + /* escape and alt */ + T("bare esc resolves on timeout", "\x1b", "key ESCAPE mods=- press"); + T("alt-a", "\x1b" "a", "key a mods=A press text=a"); + T("alt-ctrl-a", "\x1b\x01", "key a mods=CA press"); + + /* legacy CSI */ + T("up", "\x1b[A", "key ARROW_UP mods=- press"); + T("ctrl-up", "\x1b[1;5A", "key ARROW_UP mods=C press"); + T("shift-alt-left", "\x1b[1;4D", "key ARROW_LEFT mods=SA press"); + T("home", "\x1b[H", "key HOME mods=- press"); + T("delete", "\x1b[3~", "key DELETE mods=- press"); + T("f5", "\x1b[15~", "key F5 mods=- press"); + T("ss3 f1", "\x1bOP", "key F1 mods=- press"); + + /* kitty keyboard protocol */ + T("kitty a", "\x1b[97u", "key a mods=- press text=a"); + T("kitty ctrl-a", "\x1b[97;5u", "key a mods=C press"); + T("kitty release", "\x1b[97;1:3u", "key a mods=- release text=a"); + T("kitty repeat", "\x1b[97;1:2u", "key a mods=- repeat text=a"); + T("kitty esc", "\x1b[27u", "key ESCAPE mods=- press"); + T("kitty shifted", "\x1b[97:65;2u", "key a mods=S press text=A"); + + /* mouse */ + T("mouse press", "\x1b[<0;10;5M", "mouse btn=1 press at=9,4 mods=-"); + T("mouse release", "\x1b[<0;10;5m", "mouse btn=1 release at=9,4 mods=-"); + T("mouse drag", "\x1b[<32;3;3M", "mouse btn=1 motion at=2,2 mods=-"); + T("wheel up", "\x1b[<64;1;1M", "mouse btn=4 press at=0,0 mods=-"); + T("ctrl-click", "\x1b[<16;2;2M", "mouse btn=1 press at=1,1 mods=C"); + + /* focus */ + T("focus in", "\x1b[I", "focus in"); + T("focus out", "\x1b[O", "focus out"); + + /* paste */ + T("bracketed paste", "\x1b[200~hello\x1b[201~", "paste len=5 text=hello"); + T("paste keeps control bytes", "\x1b[200~a\rb\x1b[201~", + "paste len=3 text=a\rb"); + + /* split reads: the thing that breaks naive parsers */ + expect_split("split CSI", "\x1b[1;5A", 6, 3, "key ARROW_UP mods=C press"); + expect_split("split utf8", "\xf0\x9f\x92\x80", 4, 2, + "key UNIDENTIFIED mods=- press text=\xf0\x9f\x92\x80"); + expect_split("split paste", "\x1b[200~hi\x1b[201~", 14, 8, "paste len=2 text=hi"); + expect_split("esc then key is alt", "\x1b" "b", 2, 1, "key b mods=A press text=b"); + + printf("\n%s (%d failures)\n", fails ? "FAILED" : "all green", fails); + return fails ? 1 : 0; +} diff --git a/tests/live_input.py b/tests/live_input.py new file mode 100644 index 0000000..30dd12b --- /dev/null +++ b/tests/live_input.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Live check of decode -> re-encode. + +The pane runs `cat -v`, which prints control bytes visibly, so whatever the +inner app *actually receives* ends up on our screen. That is the only honest +way to test that we re-encode for the pane rather than forwarding bytes. +""" +import os, pty, select, struct, sys, termios, time, fcntl, signal + +BIN = os.path.join(os.path.dirname(__file__), "..", "build", "sl0ptty") +INNER = ["--", "/bin/sh", "-c", "stty raw -echo; cat -v"] + + +def spawn(cols=80, rows=10, inner=None): + pid, fd = pty.fork() + if pid == 0: + os.environ["TERM"] = "xterm-ghostty" + os.execv(BIN, [BIN] + (inner or INNER)) + os._exit(127) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + return pid, fd + + +def drain(fd, idle=0.3, limit=3.0): + buf, last, start = b"", time.time(), time.time() + while time.time() - start < limit: + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + buf += chunk + last = time.time() + elif time.time() - last > idle: + break + return buf + + +fails = 0 + + +def check(name, cond, detail=""): + global fails + if not cond: + fails += 1 + print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}") + + +def main(): + pid, fd = spawn() + drain(fd) + + cases = [ + # name, what the outer terminal sends us, what the pane must receive + ("legacy ctrl-up survives", b"\x1b[1;5A", "^[[1;5A"), + # ctrl-a is the prefix, so ctrl-b is the honest test of a kitty ctrl key + ("kitty ctrl-b -> legacy ^B", b"\x1b[98;5u", "^B"), + ("kitty plain 'z' -> 'z'", b"\x1b[122u", "z"), + # each case must leave the prefix state clean for the next one + ("prefix + unbound key is swallowed", b"\x01x", ""), + ("C-a C-a sends a literal ^A", b"\x01\x01", "^A"), + ("kitty enter -> \\r", b"\x1b[13u", "^M"), + ("alt-b -> ESC b", b"\x1bb", "^[b"), + ("utf8 passes through", "é".encode(), "M-CM-)"), + ("paste is unwrapped for a plain app", b"\x1b[200~xy\x1b[201~", "xy"), + ] + + for name, send, want in cases: + os.write(fd, send) + out = drain(fd, idle=0.25, limit=1.5) + text = out.decode("utf-8", "replace") + if want == "": + # nothing may reach the pane: no repaint at all + check(name, out == b"", repr(text[-80:])) + else: + check(name, want in text, repr(text[-80:])) + + # A key release must not reach an app that never asked for one. + os.write(fd, b"\x1b[97;1:3u") + out = drain(fd, idle=0.25, limit=1.0).decode("utf-8", "replace") + check("key release is not sent to a legacy app", "a" not in out.replace("\x1b", ""), + repr(out[-80:])) + + # Mouse events must not leak into an app with no mouse tracking on. + os.write(fd, b"\x1b[<0;5;5M\x1b[<0;5;5m") + out = drain(fd, idle=0.25, limit=1.0).decode("utf-8", "replace") + check("mouse does not leak into a non-mouse app", "[<" not in out, repr(out[-80:])) + + os.write(fd, b"\x01q") + drain(fd, idle=0.2, limit=1.0) + try: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + + # --- the other direction: a pane that negotiates modern protocols ----- + # Same keys, same client; only the pane's own state differs. Passing here + # *and* above means we encode per pane rather than forwarding bytes. + inner = r'stty raw -echo; printf "\033[>1u\033[?1000h\033[?1006h"; cat -v' + pid, fd = spawn(inner=["--", "/bin/sh", "-c", inner]) + drain(fd) + + os.write(fd, b"\x02") # legacy ctrl-b in + out = drain(fd, idle=0.3, limit=2.0).decode("utf-8", "replace") + check("ctrl-b -> kitty CSI u for a kitty pane", "^[[98;5u" in out, repr(out[-80:])) + + os.write(fd, b"\x1b[<0;7;3M") # SGR mouse press in + out = drain(fd, idle=0.3, limit=2.0).decode("utf-8", "replace") + check("mouse reaches a pane that asked for it", "^[[<0;7;3M" in out, + repr(out[-80:])) + + os.write(fd, b"\x01q") + drain(fd, idle=0.2, limit=1.0) + try: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + + print(f"\n{'FAILED' if fails else 'all green'} ({fails} failures)") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) |
