aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:08:30 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:08:30 +0200
commit7521f217a5ed98cef16db5837a72fae32dce9552 (patch)
tree396837adb0cd65066d78f32eb3b6a9de7eabf67a /tests
parentsl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer (diff)
downloadslosh-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.c119
-rw-r--r--tests/live_input.py129
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())